diff options
Diffstat (limited to '')
89 files changed, 4051 insertions, 3833 deletions
diff --git a/.gitignore b/.gitignore index fb3156ab1..2074887ad 100644 --- a/.gitignore +++ b/.gitignore @@ -110,6 +110,7 @@ ENV/  # Logfiles  log.* +*.log.*  # Custom user configuration  config.yml diff --git a/LICENSE-THIRD-PARTY b/LICENSE-THIRD-PARTY new file mode 100644 index 000000000..eacd9b952 --- /dev/null +++ b/LICENSE-THIRD-PARTY @@ -0,0 +1,88 @@ +--------------------------------------------------------------------------------------------------- +                                       BSD 3-Clause License +Applies to: +    - Copyright (c) 2008-Present, IPython Development Team +      Copyright (c) 2001-2007, Fernando Perez <[email protected]> +      Copyright (c) 2001, Janko Hauser <[email protected]> +      Copyright (c) 2001, Nathaniel Gray <[email protected]> +      All rights reserved. +        - bot/exts/info/codeblock/_parsing.py: _RE_PYTHON_REPL and portions of _RE_IPYTHON_REPL +--------------------------------------------------------------------------------------------------- + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this +  list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, +  this list of conditions and the following disclaimer in the documentation +  and/or other materials provided with the distribution. + +* Neither the name of the copyright holder nor the names of its +  contributors may be used to endorse or promote products derived from +  this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +--------------------------------------------------------------------------------------------------- +                           PYTHON SOFTWARE FOUNDATION LICENSE VERSION 2 +Applies to: +    - Copyright © 2001-2020 Python Software Foundation. All rights reserved. +        - tests/_autospec.py: _decoration_helper +--------------------------------------------------------------------------------------------------- + +1. This LICENSE AGREEMENT is between the Python Software Foundation +("PSF"), and the Individual or Organization ("Licensee") accessing and +otherwise using this software ("Python") in source or binary form and +its associated documentation. + +2. Subject to the terms and conditions of this License Agreement, PSF hereby +grants Licensee a nonexclusive, royalty-free, world-wide license to reproduce, +analyze, test, perform and/or display publicly, prepare derivative works, +distribute, and otherwise use Python alone or in any derivative version, +provided, however, that PSF's License Agreement and PSF's notice of copyright, +i.e., "Copyright (c) 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009, 2010, +2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2019, 2020 Python Software Foundation; +All Rights Reserved" are retained in Python alone or in any derivative version +prepared by Licensee. + +3. In the event Licensee prepares a derivative work that is based on +or incorporates Python or any part thereof, and wants to make +the derivative work available to others as provided herein, then +Licensee hereby agrees to include in any such work a brief summary of +the changes made to Python. + +4. PSF is making Python available to Licensee on an "AS IS" +basis.  PSF MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR +IMPLIED.  BY WAY OF EXAMPLE, BUT NOT LIMITATION, PSF MAKES NO AND +DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS +FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON WILL NOT +INFRINGE ANY THIRD PARTY RIGHTS. + +5. PSF SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON +FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS +A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON, +OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. + +6. This License Agreement will automatically terminate upon a material +breach of its terms and conditions. + +7. Nothing in this License Agreement shall be deemed to create any +relationship of agency, partnership, or joint venture between PSF and +Licensee.  This License Agreement does not grant permission to use PSF +trademarks or trade name in a trademark sense to endorse or promote +products or services of Licensee, or any third party. + +8. By copying, installing or otherwise using Python, Licensee +agrees to be bound by the terms and conditions of this License +Agreement. @@ -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" +"discord.py" = "~=1.5.0"  feedparser = "~=5.2"  fuzzywuzzy = "~=0.17"  lxml = "~=4.4" diff --git a/Pipfile.lock b/Pipfile.lock index 50ddd478c..becd85c55 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@  {      "_meta": {          "hash": { -            "sha256": "1905fd7eb15074ddbf04f2177b6cdd65edc4c74cb5fcbf4e6ca08ef649ba8a3c" +            "sha256": "073fd0c51749aafa188fdbe96c5b90dd157cb1d23bdd144801fb0d0a369ffa88"          },          "pipfile-spec": 6,          "requires": { @@ -18,11 +18,11 @@      "default": {          "aio-pika": {              "hashes": [ -                "sha256:c4cbbeb85b3c7bf81bc127371846cd949e6231717ce1e6ac7ee1dd5ede21f866", -                "sha256:ec7fef24f588d90314873463ab4f2c3debce0bd8830e49e3786586be96bc2e8e" +                "sha256:9773440a89840941ac3099a7720bf9d51e8764a484066b82ede4d395660ff430", +                "sha256:a8065be3c722eb8f9fff8c0e7590729e7782202cdb9363d9830d7d5d47b45c7c"              ],              "index": "pypi", -            "version": "==6.6.1" +            "version": "==6.7.1"          },          "aiodns": {              "hashes": [ @@ -50,6 +50,14 @@              "index": "pypi",              "version": "==3.6.2"          }, +        "aioping": { +            "hashes": [ +                "sha256:8900ef2f5a589ba0c12aaa9c2d586f5371820d468d21b374ddb47ef5fc8f297c", +                "sha256:f983d86acab3a04c322731ce88d42c55d04d2842565fc8532fe10c838abfd275" +            ], +            "index": "pypi", +            "version": "==0.3.1" +        },          "aioredis": {              "hashes": [                  "sha256:15f8af30b044c771aee6787e5ec24694c048184c7b9e54c3b60c750a4b93273a", @@ -73,6 +81,18 @@              ],              "version": "==0.7.12"          }, +        "async-rediscache": { +            "extras": [ +                "fakeredis" +            ], +            "hashes": [ +                "sha256:6be8a657d724ccbcfb1946d29a80c3478c5f9ecd2f78a0a26d2f4013a622258f", +                "sha256:c25e4fff73f64d20645254783c3224a4c49e083e3fab67c44f17af944c5e26af" +            ], +            "index": "pypi", +            "markers": "python_version ~= '3.7'", +            "version": "==0.1.4" +        },          "async-timeout": {              "hashes": [                  "sha256:0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f", @@ -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": [ @@ -99,12 +119,12 @@          },          "beautifulsoup4": {              "hashes": [ -                "sha256:73cc4d115b96f79c7d77c1c7f7a0a8d4c57860d1041df407dd1aae7f07a77fd7", -                "sha256:a6237df3c32ccfaee4fd201c8f5f9d9df619b93121d01353a64a73ce8c6ef9a8", -                "sha256:e718f2342e2e099b640a34ab782407b7b676f47ee272d6739e60b8ea23829f2c" +                "sha256:1edf5e39f3a5bc6e38b235b369128416c7239b34f692acccececb040233032a1", +                "sha256:5dfe44f8fddc89ac5453f02659d3ab1668f2c0d9684839f0785037e8c6d9ac8d", +                "sha256:645d833a828722357038299b7f6879940c11dddd95b900fe5387c258b72bb883"              ],              "index": "pypi", -            "version": "==4.9.1" +            "version": "==4.9.2"          },          "certifi": {              "hashes": [ @@ -115,36 +135,44 @@          },          "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:005f2bfe11b6745d726dbb07ace4d53f057de66e336ff92d61b8c7e9c8f4777d", +                "sha256:09e96138280241bd355cd585148dec04dbbedb4f46128f340d696eaafc82dd7b", +                "sha256:0b1ad452cc824665ddc682400b62c9e4f5b64736a2ba99110712fdee5f2505c4", +                "sha256:0ef488305fdce2580c8b2708f22d7785ae222d9825d3094ab073e22e93dfe51f", +                "sha256:15f351bed09897fbda218e4db5a3d5c06328862f6198d4fb385f3e14e19decb3", +                "sha256:22399ff4870fb4c7ef19fff6eeb20a8bbf15571913c181c78cb361024d574579", +                "sha256:23e5d2040367322824605bc29ae8ee9175200b92cb5483ac7d466927a9b3d537", +                "sha256:2791f68edc5749024b4722500e86303a10d342527e1e3bcac47f35fbd25b764e", +                "sha256:2f9674623ca39c9ebe38afa3da402e9326c245f0f5ceff0623dccdac15023e05", +                "sha256:3363e77a6176afb8823b6e06db78c46dbc4c7813b00a41300a4873b6ba63b171", +                "sha256:33c6cdc071ba5cd6d96769c8969a0531be2d08c2628a0143a10a7dcffa9719ca", +                "sha256:3b8eaf915ddc0709779889c472e553f0d3e8b7bdf62dab764c8921b09bf94522", +                "sha256:3cb3e1b9ec43256c4e0f8d2837267a70b0e1ca8c4f456685508ae6106b1f504c", +                "sha256:3eeeb0405fd145e714f7633a5173318bd88d8bbfc3dd0a5751f8c4f70ae629bc", +                "sha256:44f60519595eaca110f248e5017363d751b12782a6f2bd6a7041cba275215f5d", +                "sha256:4d7c26bfc1ea9f92084a1d75e11999e97b62d63128bcc90c3624d07813c52808", +                "sha256:529c4ed2e10437c205f38f3691a68be66c39197d01062618c55f74294a4a4828", +                "sha256:6642f15ad963b5092d65aed022d033c77763515fdc07095208f15d3563003869", +                "sha256:85ba797e1de5b48aa5a8427b6ba62cf69607c18c5d4eb747604b7302f1ec382d", +                "sha256:8f0f1e499e4000c4c347a124fa6a27d37608ced4fe9f7d45070563b7c4c370c9", +                "sha256:a624fae282e81ad2e4871bdb767e2c914d0539708c0f078b5b355258293c98b0", +                "sha256:b0358e6fefc74a16f745afa366acc89f979040e0cbc4eec55ab26ad1f6a9bfbc", +                "sha256:bbd2f4dfee1079f76943767fce837ade3087b578aeb9f69aec7857d5bf25db15", +                "sha256:bf39a9e19ce7298f1bd6a9758fa99707e9e5b1ebe5e90f2c3913a47bc548747c", +                "sha256:c11579638288e53fc94ad60022ff1b67865363e730ee41ad5e6f0a17188b327a", +                "sha256:c150eaa3dadbb2b5339675b88d4573c1be3cb6f2c33a6c83387e10cc0bf05bd3", +                "sha256:c53af463f4a40de78c58b8b2710ade243c81cbca641e34debf3396a9640d6ec1", +                "sha256:cb763ceceae04803adcc4e2d80d611ef201c73da32d8f2722e9d0ab0c7f10768", +                "sha256:cc75f58cdaf043fe6a7a6c04b3b5a0e694c6a9e24050967747251fb80d7bce0d", +                "sha256:d80998ed59176e8cba74028762fbd9b9153b9afc71ea118e63bbf5d4d0f9552b", +                "sha256:de31b5164d44ef4943db155b3e8e17929707cac1e5bd2f363e67a56e3af4af6e", +                "sha256:e66399cf0fc07de4dce4f588fc25bfe84a6d1285cc544e67987d22663393926d", +                "sha256:f0620511387790860b249b9241c2f13c3a80e21a73e0b861a2df24e9d6f56730", +                "sha256:f4eae045e6ab2bb54ca279733fe4eb85f1effda392666308250714e01907f394", +                "sha256:f92cdecb618e5fa4658aeb97d5eb3d2f47aa94ac6477c6daf0f306c5a3b9e6b1", +                "sha256:f92f789e4f9241cd262ad7a555ca2c648a98178a953af117ef7fad46aa1d5591" +            ], +            "version": "==1.14.3"          },          "chardet": {              "hashes": [ @@ -177,22 +205,13 @@              "index": "pypi",              "version": "==4.3.2"          }, -        "discord": { -            "hashes": [ -                "sha256:9d4debb4a37845543bd4b92cb195bc53a302797333e768e70344222857ff1559", -                "sha256:ff6653655e342e7721dfb3f10421345fd852c2a33f2cca912b1c39b3778a9429" -            ], -            "index": "pypi", -            "py": "~=1.4.0", -            "version": "==1.0.1" -        },          "discord.py": {              "hashes": [ -                "sha256:2b1846bfa382b54f4eace8e437a9f59f185388c5b08749ac0e1bbd98e05bfde5", -                "sha256:f3db9531fccc391f51de65cfa46133106a9ba12ff2927aca6c14bffd3b7f17b5" +                "sha256:3acb61fde0d862ed346a191d69c46021e6063673f63963bc984ae09a685ab211", +                "sha256:e71089886aa157341644bdecad63a72ff56b44406b1a6467b66db31c8e5a5a15"              ], -            "markers": "python_full_version >= '3.5.3'", -            "version": "==1.4.0" +            "index": "pypi", +            "version": "==1.5.0"          },          "docutils": {              "hashes": [ @@ -204,11 +223,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 +368,11 @@          },          "markdownify": {              "hashes": [ -                "sha256:28ce67d1888e4908faaab7b04d2193cda70ea4f902f156a21d0aaea55e63e0a1" +                "sha256:30be8340724e706c9e811c27fe8c1542cf74a15b46827924fff5c54b40dd9b0d", +                "sha256:a69588194fd76634f0139d6801b820fd652dc5eeba9530e90d323dfdc0155252"              ],              "index": "pypi", -            "version": "==0.4.1" +            "version": "==0.5.3"          },          "markupsafe": {              "hashes": [ @@ -396,11 +415,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 +510,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 +574,11 @@          },          "sentry-sdk": {              "hashes": [ -                "sha256:21b17d6aa064c0fb703a7c00f77cf6c9c497cf2f83345c28892980a5e742d116", -                "sha256:4fc97114c77d005467b9b1a29f042e2bc01923cb683b0ef0bbda46e79fa12532" +                "sha256:c9c0fa1412bad87104c4eee8dd36c7bbf60b0d92ae917ab519094779b22e6d9a", +                "sha256:e159f7c919d19ae86e5a4ff370fccc45149fab461fbeb93fb5a735a0b33a9cb1"              ],              "index": "pypi", -            "version": "==0.16.3" +            "version": "==0.17.8"          },          "six": {              "hashes": [ @@ -588,7 +607,7 @@                  "sha256:1634eea42ab371d3d346309b93df7870a88610f0725d47528be902a0d95ecc55",                  "sha256:a59dc181727e95d25f781f0eb4fd1825ff45590ec8ff49eadfd7f1a537cc0232"              ], -            "markers": "python_version >= '3.5'", +            "markers": "python_version >= '3.0'",              "version": "==2.0.1"          },          "sphinx": { @@ -665,26 +684,26 @@          },          "yarl": {              "hashes": [ -                "sha256:040b237f58ff7d800e6e0fd89c8439b841f777dd99b4a9cca04d6935564b9409", -                "sha256:17668ec6722b1b7a3a05cc0167659f6c95b436d25a36c2d52db0eca7d3f72593", -                "sha256:3a584b28086bc93c888a6c2aa5c92ed1ae20932f078c46509a66dce9ea5533f2", -                "sha256:4439be27e4eee76c7632c2427ca5e73703151b22cae23e64adb243a9c2f565d8", -                "sha256:48e918b05850fffb070a496d2b5f97fc31d15d94ca33d3d08a4f86e26d4e7c5d", -                "sha256:9102b59e8337f9874638fcfc9ac3734a0cfadb100e47d55c20d0dc6087fb4692", -                "sha256:9b930776c0ae0c691776f4d2891ebc5362af86f152dd0da463a6614074cb1b02", -                "sha256:b3b9ad80f8b68519cc3372a6ca85ae02cc5a8807723ac366b53c0f089db19e4a", -                "sha256:bc2f976c0e918659f723401c4f834deb8a8e7798a71be4382e024bcc3f7e23a8", -                "sha256:c22c75b5f394f3d47105045ea551e08a3e804dc7e01b37800ca35b58f856c3d6", -                "sha256:c52ce2883dc193824989a9b97a76ca86ecd1fa7955b14f87bf367a61b6232511", -                "sha256:ce584af5de8830d8701b8979b18fcf450cef9a382b1a3c8ef189bedc408faf1e", -                "sha256:da456eeec17fa8aa4594d9a9f27c0b1060b6a75f2419fe0c00609587b2695f4a", -                "sha256:db6db0f45d2c63ddb1a9d18d1b9b22f308e52c83638c26b422d520a815c4b3fb", -                "sha256:df89642981b94e7db5596818499c4b2219028f2a528c9c37cc1de45bf2fd3a3f", -                "sha256:f18d68f2be6bf0e89f1521af2b1bb46e66ab0018faafa81d70f358153170a317", -                "sha256:f379b7f83f23fe12823085cd6b906edc49df969eb99757f58ff382349a3303c6" +                "sha256:04a54f126a0732af75e5edc9addeaa2113e2ca7c6fce8974a63549a70a25e50e", +                "sha256:3cc860d72ed989f3b1f3abbd6ecf38e412de722fb38b8f1b1a086315cf0d69c5", +                "sha256:5d84cc36981eb5a8533be79d6c43454c8e6a39ee3118ceaadbd3c029ab2ee580", +                "sha256:5e447e7f3780f44f890360ea973418025e8c0cdcd7d6a1b221d952600fd945dc", +                "sha256:61d3ea3c175fe45f1498af868879c6ffeb989d4143ac542163c45538ba5ec21b", +                "sha256:67c5ea0970da882eaf9efcf65b66792557c526f8e55f752194eff8ec722c75c2", +                "sha256:6f6898429ec3c4cfbef12907047136fd7b9e81a6ee9f105b45505e633427330a", +                "sha256:7ce35944e8e61927a8f4eb78f5bc5d1e6da6d40eadd77e3f79d4e9399e263921", +                "sha256:b7c199d2cbaf892ba0f91ed36d12ff41ecd0dde46cbf64ff4bfe997a3ebc925e", +                "sha256:c15d71a640fb1f8e98a1423f9c64d7f1f6a3a168f803042eaf3a5b5022fde0c1", +                "sha256:c22607421f49c0cb6ff3ed593a49b6a99c6ffdeaaa6c944cdda83c2393c8864d", +                "sha256:c604998ab8115db802cc55cb1b91619b2831a6128a62ca7eea577fc8ea4d3131", +                "sha256:d088ea9319e49273f25b1c96a3763bf19a882cff774d1792ae6fba34bd40550a", +                "sha256:db9eb8307219d7e09b33bcb43287222ef35cbcf1586ba9472b0a4b833666ada1", +                "sha256:e31fef4e7b68184545c3d68baec7074532e077bd1906b040ecfba659737df188", +                "sha256:e32f0fb443afcfe7f01f95172b66f279938fbc6bdaebe294b0ff6747fb6db020", +                "sha256:fcbe419805c9b20db9a51d33b942feddbf6e7fb468cb20686fd7089d4164c12a"              ],              "markers": "python_version >= '3.5'", -            "version": "==1.5.1" +            "version": "==1.6.0"          }      },      "develop": { @@ -697,11 +716,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 +732,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 +794,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 +856,11 @@          },          "identify": {              "hashes": [ -                "sha256:110ed090fec6bce1aabe3c72d9258a9de82207adeaa5a05cd75c635880312f9a", -                "sha256:ccd88716b890ecbe10920659450a635d2d25de499b9a638525a48b48261d989b" +                "sha256:7c22c384a2c9b32c5cc891d13f923f6b2653aa83e2d75d8f79be240d6c86c4f4", +                "sha256:da683bfb7669fa749fc7731f378229e2dbf29a1d1337cbde04106f02236eb29d"              ],              "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.5"          },          "mccabe": {              "hashes": [ @@ -852,9 +871,10 @@          },          "nodeenv": {              "hashes": [ -                "sha256:4b0b77afa3ba9b54f4b6396e60b0c83f59eaeb2d63dc3cc7a70f7f4af96c82bc" +                "sha256:5304d424c529c997bc888453aeaa6362d242b6b4631e90f3d4bf1b290f1c84a9", +                "sha256:ab45090ae383b716c4ef89e690c41ff8c2b257b85b309f01f3654df3d084bd7c"              ], -            "version": "==1.4.0" +            "version": "==1.5.0"          },          "pep8-naming": {              "hashes": [ @@ -866,11 +886,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 +902,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 +957,19 @@          },          "unittest-xml-reporting": {              "hashes": [ -                "sha256:74eaf7739a7957a74f52b8187c5616f61157372189bef0a32ba5c30bbc00e58a", -                "sha256:e09b8ae70cce9904cdd331f53bf929150962869a5324ab7ff3dd6c8b87e01f7d" +                "sha256:7bf515ea8cb244255a25100cd29db611a73f8d3d0aaf672ed3266307e14cc1ca", +                "sha256:984cebba69e889401bfe3adb9088ca376b3a1f923f0590d005126c1bffd1a695"              ],              "index": "pypi", -            "version": "==3.0.2" +            "version": "==3.0.4"          },          "virtualenv": {              "hashes": [ -                "sha256: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 8770ac31b..367be1300 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -1,13 +1,15 @@ +import asyncio  import logging  import discord  import sentry_sdk +from async_rediscache import RedisSession  from discord.ext.commands import when_mentioned_or  from sentry_sdk.integrations.aiohttp import AioHttpIntegration  from sentry_sdk.integrations.logging import LoggingIntegration  from sentry_sdk.integrations.redis import RedisIntegration -from bot import constants, patches +from bot import constants  from bot.bot import Bot  from bot.utils.extensions import EXTENSIONS @@ -26,14 +28,41 @@ sentry_sdk.init(      ]  ) +# Create the redis session instance. +redis_session = RedisSession( +    address=(constants.Redis.host, constants.Redis.port), +    password=constants.Redis.password, +    minsize=1, +    maxsize=20, +    use_fakeredis=constants.Redis.use_fakeredis, +    global_namespace="bot", +) + +# Connect redis session to ensure it's connected before we try to access Redis +# from somewhere within the bot. We create the event loop in the same way +# discord.py normally does and pass it to the bot's __init__. +loop = asyncio.get_event_loop() +loop.run_until_complete(redis_session.connect()) + +  # Instantiate the bot.  allowed_roles = [discord.Object(id_) for id_ in constants.MODERATION_ROLES] +intents = discord.Intents().all() +intents.presences = False +intents.dm_typing = False +intents.dm_reactions = False +intents.invites = False +intents.webhooks = False +intents.integrations = False  bot = Bot( +    redis_session=redis_session, +    loop=loop,      command_prefix=when_mentioned_or(constants.Bot.prefix), -    activity=discord.Game(name="Commands: !help"), +    activity=discord.Game(name=f"Commands: {constants.Bot.prefix}help"),      case_insensitive=True,      max_messages=10_000, -    allowed_mentions=discord.AllowedMentions(everyone=False, roles=allowed_roles) +    allowed_mentions=discord.AllowedMentions(everyone=False, roles=allowed_roles), +    intents=intents,  )  # Load extensions. @@ -44,8 +73,4 @@ if not constants.HelpChannels.enable:  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'): -    patches.message_edited_at.apply_patch() -  bot.run(constants.Bot.token) 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/constants.py b/bot/constants.py index 17f14fec0..b615dcd19 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,16 @@ class Channels(metaclass=YAMLGetter):      section = "guild"      subsection = "channels" +    admin_announcements: int      admin_spam: int      admins: int      announcements: int      attachment_log: int      big_brother_logs: int      bot_commands: int +    change_log: int +    code_help_voice: int +    code_help_voice_2: int      cooldown: int      defcon: int      dev_contrib: int @@ -412,9 +404,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,11 +417,15 @@ class Channels(metaclass=YAMLGetter):      off_topic_2: int      organisation: int      python_discussion: int +    python_events: int +    python_news: int      reddit: int +    staff_announcements: int      talent_pool: int      user_event_announcements: int      user_log: int      verification: int +    voice_gate: int      voice_log: int @@ -460,9 +458,11 @@ class Roles(metaclass=YAMLGetter):      owners: int      partners: int      python_community: int +    sprinters: int      team_leaders: int      unverified: int      verified: int  # This is the Developers role on PyDis, here named verified for readability reasons. +    voice_verified: int  class Guild(metaclass=YAMLGetter): @@ -471,10 +471,10 @@ class Guild(metaclass=YAMLGetter):      id: int      invite: str  # Discord invite, gets embedded in chat      moderation_channels: List[int] +    moderation_categories: List[int]      moderation_roles: List[int]      modlog_blacklist: List[int]      reminder_whitelist: List[int] -    staff_channels: List[int]      staff_roles: List[int] @@ -533,6 +533,15 @@ class BigBrother(metaclass=YAMLGetter):      header_message_limit: int +class CodeBlock(metaclass=YAMLGetter): +    section = 'code_block' + +    channel_whitelist: List[int] +    cooldown_channels: List[int] +    cooldown_seconds: int +    minimum_lines: int + +  class Free(metaclass=YAMLGetter):      section = 'free' @@ -565,13 +574,6 @@ class RedirectOutput(metaclass=YAMLGetter):      delete_delay: int -class Sync(metaclass=YAMLGetter): -    section = 'sync' - -    confirm_timeout: int -    max_diff: int - -  class PythonNews(metaclass=YAMLGetter):      section = 'python_news' @@ -590,6 +592,14 @@ class Verification(metaclass=YAMLGetter):      kick_confirmation_threshold: float +class VoiceGate(metaclass=YAMLGetter): +    section = "voice_gate" + +    minimum_days_verified: int +    minimum_messages: int +    bot_message_delete_delay: int + +  class Event(Enum):      """      Event names. This does not include every event (for example, raw @@ -628,9 +638,11 @@ MODERATION_ROLES = Guild.moderation_roles  STAFF_ROLES = Guild.staff_roles  # Channel combinations -STAFF_CHANNELS = Guild.staff_channels  MODERATION_CHANNELS = Guild.moderation_channels +# Category combinations +MODERATION_CATEGORIES = Guild.moderation_categories +  # Bot replies  NEGATIVE_REPLIES = [      "Noooooo!!", diff --git a/bot/converters.py b/bot/converters.py index 1358cbf1e..2e118d476 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -2,6 +2,7 @@ import logging  import re  import typing as t  from datetime import datetime +from functools import partial  from ssl import CertificateError  import dateutil.parser @@ -10,6 +11,7 @@ import discord  from aiohttp import ClientConnectorError  from dateutil.relativedelta import relativedelta  from discord.ext.commands import BadArgument, Bot, Context, Converter, IDConverter, UserConverter +from discord.utils import DISCORD_EPOCH, snowflake_time  from bot.api import ResponseCodeError  from bot.constants import URLs @@ -17,6 +19,9 @@ from bot.utils.regex import INVITE_RE  log = logging.getLogger(__name__) +DISCORD_EPOCH_DT = datetime.utcfromtimestamp(DISCORD_EPOCH / 1000) +RE_USER_MENTION = re.compile(r"<@!?([0-9]+)>$") +  def allowed_strings(*values, preserve_case: bool = False) -> t.Callable[[str], str]:      """ @@ -172,17 +177,42 @@ class ValidURL(Converter):          return url -class InfractionSearchQuery(Converter): -    """A converter that checks if the argument is a Discord user, and if not, falls back to a string.""" +class Snowflake(IDConverter): +    """ +    Converts to an int if the argument is a valid Discord snowflake. + +    A snowflake is valid if: + +    * It consists of 15-21 digits (0-9) +    * Its parsed datetime is after the Discord epoch +    * Its parsed datetime is less than 1 day after the current time +    """ + +    async def convert(self, ctx: Context, arg: str) -> int: +        """ +        Ensure `arg` matches the ID pattern and its timestamp is in range. + +        Return `arg` as an int if it's a valid snowflake. +        """ +        error = f"Invalid snowflake {arg!r}" + +        if not self._get_id_match(arg): +            raise BadArgument(error) + +        snowflake = int(arg) -    @staticmethod -    async def convert(ctx: Context, arg: str) -> t.Union[discord.Member, str]: -        """Check if the argument is a Discord user, and if not, falls back to a string."""          try: -            maybe_snowflake = arg.strip("<@!>") -            return await ctx.bot.fetch_user(maybe_snowflake) -        except (discord.NotFound, discord.HTTPException): -            return arg +            time = snowflake_time(snowflake) +        except (OverflowError, OSError) as e: +            # Not sure if this can ever even happen, but let's be safe. +            raise BadArgument(f"{error}: {e}") + +        if time < DISCORD_EPOCH_DT: +            raise BadArgument(f"{error}: timestamp is before the Discord epoch.") +        elif (datetime.utcnow() - time).days < -1: +            raise BadArgument(f"{error}: timestamp is too far into the future.") + +        return snowflake  class Subreddit(Converter): @@ -447,14 +477,13 @@ class UserMentionOrID(UserConverter):      """      Converts to a `discord.User`, but only if a mention or userID is provided. -    Unlike the default `UserConverter`, it does allow conversion from name, or name#descrim. - +    Unlike the default `UserConverter`, it doesn't allow conversion from a name or name#descrim.      This is useful in cases where that lookup strategy would lead to ambiguity.      """      async def convert(self, ctx: Context, argument: str) -> discord.User:          """Convert the `arg` to a `discord.User`.""" -        match = self._get_id_match(argument) or re.match(r'<@!?([0-9]+)>$', argument) +        match = self._get_id_match(argument) or RE_USER_MENTION.match(argument)          if match is not None:              return await super().convert(ctx, argument) @@ -507,5 +536,19 @@ class FetchedUser(UserConverter):              raise BadArgument(f"User `{arg}` does not exist") +def _snowflake_from_regex(pattern: t.Pattern, arg: str) -> int: +    """ +    Extract the snowflake from `arg` using a regex `pattern` and return it as an int. + +    The snowflake is expected to be within the first capture group in `pattern`. +    """ +    match = pattern.match(arg) +    if not match: +        raise BadArgument(f"Mention {str!r} is invalid.") + +    return int(match.group(1)) + +  Expiry = t.Union[Duration, ISODateTime]  FetchedMember = t.Union[discord.Member, FetchedUser] +UserMention = partial(_snowflake_from_regex, RE_USER_MENTION) diff --git a/bot/decorators.py b/bot/decorators.py index 500197c89..063c8f878 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -1,30 +1,28 @@ +import asyncio  import logging -import random -from asyncio import Lock, create_task, sleep +import typing as t  from contextlib import suppress  from functools import wraps -from typing import Callable, Container, Optional, Union -from weakref import WeakValueDictionary -from discord import Colour, Embed, Member -from discord.errors import NotFound +from discord import Member, NotFound  from discord.ext import commands  from discord.ext.commands import Cog, Context -from bot.constants import Channels, ERROR_REPLIES, RedirectOutput -from bot.utils.checks import in_whitelist_check, with_role_check, without_role_check +from bot.constants import Channels, RedirectOutput +from bot.utils import function +from bot.utils.checks import in_whitelist_check  log = logging.getLogger(__name__)  def in_whitelist(      *, -    channels: Container[int] = (), -    categories: Container[int] = (), -    roles: Container[int] = (), -    redirect: Optional[int] = Channels.bot_commands, +    channels: t.Container[int] = (), +    categories: t.Container[int] = (), +    roles: t.Container[int] = (), +    redirect: t.Optional[int] = Channels.bot_commands,      fail_silently: bool = False, -) -> Callable: +) -> t.Callable:      """      Check if a command was issued in a whitelisted context. @@ -32,7 +30,7 @@ def in_whitelist(      - `channels`: a container with channel ids for whitelisted channels      - `categories`: a container with category ids for whitelisted categories -    - `roles`: a container with with role ids for whitelisted roles +    - `roles`: a container with role ids for whitelisted roles      If the command was invoked in a context that was not whitelisted, the member is either      redirected to the `redirect` channel that was passed (default: #bot-commands) or simply @@ -45,54 +43,26 @@ def in_whitelist(      return commands.check(predicate) -def with_role(*role_ids: int) -> Callable: -    """Returns True if the user has any one of the roles in role_ids.""" -    async def predicate(ctx: Context) -> bool: -        """With role checker predicate.""" -        return with_role_check(ctx, *role_ids) -    return commands.check(predicate) - - -def without_role(*role_ids: int) -> Callable: -    """Returns True if the user does not have any of the roles in role_ids.""" -    async def predicate(ctx: Context) -> bool: -        return without_role_check(ctx, *role_ids) -    return commands.check(predicate) - - -def locked() -> Callable: +def has_no_roles(*roles: t.Union[str, int]) -> t.Callable:      """ -    Allows the user to only run one instance of the decorated command at a time. - -    Subsequent calls to the command from the same author are ignored until the command has completed invocation. +    Returns True if the user does not have any of the roles specified. -    This decorator must go before (below) the `command` decorator. +    `roles` are the names or IDs of the disallowed roles.      """ -    def wrap(func: Callable) -> Callable: -        func.__locks = WeakValueDictionary() - -        @wraps(func) -        async def inner(self: Cog, ctx: Context, *args, **kwargs) -> None: -            lock = func.__locks.setdefault(ctx.author.id, Lock()) -            if lock.locked(): -                embed = Embed() -                embed.colour = Colour.red() - -                log.debug("User tried to invoke a locked command.") -                embed.description = ( -                    "You're already using this command. Please wait until it is done before you use it again." -                ) -                embed.title = random.choice(ERROR_REPLIES) -                await ctx.send(embed=embed) -                return +    async def predicate(ctx: Context) -> bool: +        try: +            await commands.has_any_role(*roles).predicate(ctx) +        except commands.MissingAnyRole: +            return True +        else: +            # This error is never shown to users, so don't bother trying to make it too pretty. +            roles_ = ", ".join(f"'{item}'" for item in roles) +            raise commands.CheckFailure(f"You have at least one of the disallowed roles: {roles_}") -            async with func.__locks.setdefault(ctx.author.id, Lock()): -                await func(self, ctx, *args, **kwargs) -        return inner -    return wrap +    return commands.check(predicate) -def redirect_output(destination_channel: int, bypass_roles: Container[int] = None) -> Callable: +def redirect_output(destination_channel: int, bypass_roles: t.Container[int] = None) -> t.Callable:      """      Changes the channel in the context of the command to redirect the output to a certain channel. @@ -100,7 +70,7 @@ def redirect_output(destination_channel: int, bypass_roles: Container[int] = Non      This decorator must go before (below) the `command` decorator.      """ -    def wrap(func: Callable) -> Callable: +    def wrap(func: t.Callable) -> t.Callable:          @wraps(func)          async def inner(self: Cog, ctx: Context, *args, **kwargs) -> None:              if ctx.channel.id == destination_channel: @@ -119,14 +89,14 @@ def redirect_output(destination_channel: int, bypass_roles: Container[int] = Non              log.trace(f"Redirecting output of {ctx.author}'s command '{ctx.command.name}' to {redirect_channel.name}")              ctx.channel = redirect_channel              await ctx.channel.send(f"Here's the output of your command, {ctx.author.mention}") -            create_task(func(self, ctx, *args, **kwargs)) +            asyncio.create_task(func(self, ctx, *args, **kwargs))              message = await old_channel.send(                  f"Hey, {ctx.author.mention}, you can find the output of your command here: "                  f"{redirect_channel.mention}"              )              if RedirectOutput.delete_invocation: -                await sleep(RedirectOutput.delete_delay) +                await asyncio.sleep(RedirectOutput.delete_delay)                  with suppress(NotFound):                      await message.delete() @@ -140,38 +110,35 @@ def redirect_output(destination_channel: int, bypass_roles: Container[int] = Non      return wrap -def respect_role_hierarchy(target_arg: Union[int, str] = 0) -> Callable: +def respect_role_hierarchy(member_arg: function.Argument) -> t.Callable:      """      Ensure the highest role of the invoking member is greater than that of the target member.      If the condition fails, a warning is sent to the invoking context. A target which is not an      instance of discord.Member will always pass. -    A value of 0 (i.e. position 0) for `target_arg` corresponds to the argument which comes after -    `ctx`. If the target argument is a kwarg, its name can instead be given. +    `member_arg` is the keyword name or position index of the parameter of the decorated command +    whose value is the target member.      This decorator must go before (below) the `command` decorator.      """ -    def wrap(func: Callable) -> Callable: +    def decorator(func: t.Callable) -> t.Callable:          @wraps(func) -        async def inner(self: Cog, ctx: Context, *args, **kwargs) -> None: -            try: -                target = kwargs[target_arg] -            except KeyError: -                try: -                    target = args[target_arg] -                except IndexError: -                    raise ValueError(f"Could not find target argument at position {target_arg}") -                except TypeError: -                    raise ValueError(f"Could not find target kwarg with key {target_arg!r}") +        async def wrapper(*args, **kwargs) -> None: +            log.trace(f"{func.__name__}: respect role hierarchy decorator called") + +            bound_args = function.get_bound_args(func, args, kwargs) +            target = function.get_arg_value(member_arg, bound_args)              if not isinstance(target, Member):                  log.trace("The target is not a discord.Member; skipping role hierarchy check.") -                await func(self, ctx, *args, **kwargs) +                await func(*args, **kwargs)                  return +            ctx = function.get_arg_value(1, bound_args)              cmd = ctx.command.name              actor = ctx.author +              if target.top_role >= actor.top_role:                  log.info(                      f"{actor} ({actor.id}) attempted to {cmd} " @@ -182,6 +149,7 @@ def respect_role_hierarchy(target_arg: Union[int, str] = 0) -> Callable:                      "someone with an equal or higher top role."                  )              else: -                await func(self, ctx, *args, **kwargs) -        return inner -    return wrap +                log.trace(f"{func.__name__}: {target.top_role=} < {actor.top_role=}; calling func") +                await func(*args, **kwargs) +        return wrapper +    return decorator diff --git a/bot/errors.py b/bot/errors.py new file mode 100644 index 000000000..65d715203 --- /dev/null +++ b/bot/errors.py @@ -0,0 +1,20 @@ +from typing import Hashable + + +class LockedResourceError(RuntimeError): +    """ +    Exception raised when an operation is attempted on a locked resource. + +    Attributes: +        `type` -- name of the locked resource's type +        `id` -- ID of the locked resource +    """ + +    def __init__(self, resource_type: str, resource_id: Hashable): +        self.type = resource_type +        self.id = resource_id + +        super().__init__( +            f"Cannot operate on {self.type.lower()} `{self.id}`; " +            "it is currently locked and in use by another operation." +        ) diff --git a/bot/exts/backend/alias.py b/bot/exts/backend/alias.py deleted file mode 100644 index c6ba8d6f3..000000000 --- a/bot/exts/backend/alias.py +++ /dev/null @@ -1,87 +0,0 @@ -import inspect -import logging - -from discord import Colour, Embed -from discord.ext.commands import ( -    Cog, Command, Context, -    clean_content, command, group, -) - -from bot.bot import Bot -from bot.converters import TagNameConverter -from bot.pagination import LinePaginator - -log = logging.getLogger(__name__) - - -class Alias (Cog): -    """Aliases for commonly used commands.""" - -    def __init__(self, bot: Bot): -        self.bot = bot - -    async def invoke(self, ctx: Context, cmd_name: str, *args, **kwargs) -> None: -        """Invokes a command with args and kwargs.""" -        log.debug(f"{cmd_name} was invoked through an alias") -        cmd = self.bot.get_command(cmd_name) -        if not cmd: -            return log.info(f'Did not find command "{cmd_name}" to invoke.') -        elif not await cmd.can_run(ctx): -            return log.info( -                f'{str(ctx.author)} tried to run the command "{cmd_name}" but lacks permission.' -            ) - -        await ctx.invoke(cmd, *args, **kwargs) - -    @command(name='aliases') -    async def aliases_command(self, ctx: Context) -> None: -        """Show configured aliases on the bot.""" -        embed = Embed( -            title='Configured aliases', -            colour=Colour.blue() -        ) -        await LinePaginator.paginate( -            ( -                f"• `{ctx.prefix}{value.name}` " -                f"=> `{ctx.prefix}{name[:-len('_alias')].replace('_', ' ')}`" -                for name, value in inspect.getmembers(self) -                if isinstance(value, Command) and name.endswith('_alias') -            ), -            ctx, embed, empty=False, max_lines=20 -        ) - -    @command(name="exception", hidden=True) -    async def tags_get_traceback_alias(self, ctx: Context) -> None: -        """Alias for invoking <prefix>tags get traceback.""" -        await self.invoke(ctx, "tags get", tag_name="traceback") - -    @group(name="get", -           aliases=("show", "g"), -           hidden=True, -           invoke_without_command=True) -    async def get_group_alias(self, ctx: Context) -> None: -        """Group for reverse aliases for commands like `tags get`, allowing for `get tags` or `get docs`.""" -        pass - -    @get_group_alias.command(name="tags", aliases=("tag", "t"), hidden=True) -    async def tags_get_alias( -            self, ctx: Context, *, tag_name: TagNameConverter = None -    ) -> None: -        """ -        Alias for invoking <prefix>tags get [tag_name]. - -        tag_name: str - tag to be viewed. -        """ -        await self.invoke(ctx, "tags get", tag_name=tag_name) - -    @get_group_alias.command(name="docs", aliases=("doc", "d"), hidden=True) -    async def docs_get_alias( -            self, ctx: Context, symbol: clean_content = None -    ) -> None: -        """Alias for invoking <prefix>docs get [symbol].""" -        await self.invoke(ctx, "docs get", symbol) - - -def setup(bot: Bot) -> None: -    """Load the Alias cog.""" -    bot.add_cog(Alias(bot)) diff --git a/bot/exts/backend/error_handler.py b/bot/exts/backend/error_handler.py index f9d4de638..c643d346e 100644 --- a/bot/exts/backend/error_handler.py +++ b/bot/exts/backend/error_handler.py @@ -10,6 +10,7 @@ from bot.api import ResponseCodeError  from bot.bot import Bot  from bot.constants import Channels, Colours  from bot.converters import TagNameConverter +from bot.errors import LockedResourceError  from bot.utils.checks import InWhitelistCheckFailure  log = logging.getLogger(__name__) @@ -75,6 +76,8 @@ class ErrorHandler(Cog):          elif isinstance(e, errors.CommandInvokeError):              if isinstance(e.original, ResponseCodeError):                  await self.handle_api_error(ctx, e.original) +            elif isinstance(e.original, LockedResourceError): +                await ctx.send(f"{e.original} Please wait for it to finish and try again later.")              else:                  await self.handle_unexpected_error(ctx, e.original)              return  # Exit early to avoid logging. diff --git a/bot/exts/backend/sync/_syncers.py b/bot/exts/backend/sync/_syncers.py index f7ba811bc..38468c2b1 100644 --- a/bot/exts/backend/sync/_syncers.py +++ b/bot/exts/backend/sync/_syncers.py @@ -1,15 +1,11 @@  import abc -import asyncio  import logging  import typing as t  from collections import namedtuple -from functools import partial -import discord -from discord import Guild, HTTPException, Member, Message, Reaction, User +from discord import Guild  from discord.ext.commands import Context -from bot import constants  from bot.api import ResponseCodeError  from bot.bot import Bot @@ -18,16 +14,12 @@ log = logging.getLogger(__name__)  # These objects are declared as namedtuples because tuples are hashable,  # something that we make use of when diffing site roles against guild roles.  _Role = namedtuple('Role', ('id', 'name', 'colour', 'permissions', 'position')) -_User = namedtuple('User', ('id', 'name', 'discriminator', 'roles', 'in_guild'))  _Diff = namedtuple('Diff', ('created', 'updated', 'deleted'))  class Syncer(abc.ABC):      """Base class for synchronising the database with objects in the Discord cache.""" -    _CORE_DEV_MENTION = f"<@&{constants.Roles.core_developers}> " -    _REACTION_EMOJIS = (constants.Emojis.check_mark, constants.Emojis.cross_mark) -      def __init__(self, bot: Bot) -> None:          self.bot = bot @@ -37,112 +29,6 @@ class Syncer(abc.ABC):          """The name of the syncer; used in output messages and logging."""          raise NotImplementedError  # pragma: no cover -    async def _send_prompt(self, message: t.Optional[Message] = None) -> t.Optional[Message]: -        """ -        Send a prompt to confirm or abort a sync using reactions and return the sent message. - -        If a message is given, it is edited to display the prompt and reactions. Otherwise, a new -        message is sent to the dev-core channel and mentions the core developers role. If the -        channel cannot be retrieved, return None. -        """ -        log.trace(f"Sending {self.name} sync confirmation prompt.") - -        msg_content = ( -            f'Possible cache issue while syncing {self.name}s. ' -            f'More than {constants.Sync.max_diff} {self.name}s were changed. ' -            f'React to confirm or abort the sync.' -        ) - -        # Send to core developers if it's an automatic sync. -        if not message: -            log.trace("Message not provided for confirmation; creating a new one in dev-core.") -            channel = self.bot.get_channel(constants.Channels.dev_core) - -            if not channel: -                log.debug("Failed to get the dev-core channel from cache; attempting to fetch it.") -                try: -                    channel = await self.bot.fetch_channel(constants.Channels.dev_core) -                except HTTPException: -                    log.exception( -                        f"Failed to fetch channel for sending sync confirmation prompt; " -                        f"aborting {self.name} sync." -                    ) -                    return None - -            allowed_roles = [discord.Object(constants.Roles.core_developers)] -            message = await channel.send( -                f"{self._CORE_DEV_MENTION}{msg_content}", -                allowed_mentions=discord.AllowedMentions(everyone=False, roles=allowed_roles) -            ) -        else: -            await message.edit(content=msg_content) - -        # Add the initial reactions. -        log.trace(f"Adding reactions to {self.name} syncer confirmation prompt.") -        for emoji in self._REACTION_EMOJIS: -            await message.add_reaction(emoji) - -        return message - -    def _reaction_check( -        self, -        author: Member, -        message: Message, -        reaction: Reaction, -        user: t.Union[Member, User] -    ) -> bool: -        """ -        Return True if the `reaction` is a valid confirmation or abort reaction on `message`. - -        If the `author` of the prompt is a bot, then a reaction by any core developer will be -        considered valid. Otherwise, the author of the reaction (`user`) will have to be the -        `author` of the prompt. -        """ -        # For automatic syncs, check for the core dev role instead of an exact author -        has_role = any(constants.Roles.core_developers == role.id for role in user.roles) -        return ( -            reaction.message.id == message.id -            and not user.bot -            and (has_role if author.bot else user == author) -            and str(reaction.emoji) in self._REACTION_EMOJIS -        ) - -    async def _wait_for_confirmation(self, author: Member, message: Message) -> bool: -        """ -        Wait for a confirmation reaction by `author` on `message` and return True if confirmed. - -        Uses the `_reaction_check` function to determine if a reaction is valid. - -        If there is no reaction within `bot.constants.Sync.confirm_timeout` seconds, return False. -        To acknowledge the reaction (or lack thereof), `message` will be edited. -        """ -        # Preserve the core-dev role mention in the message edits so users aren't confused about -        # where notifications came from. -        mention = self._CORE_DEV_MENTION if author.bot else "" - -        reaction = None -        try: -            log.trace(f"Waiting for a reaction to the {self.name} syncer confirmation prompt.") -            reaction, _ = await self.bot.wait_for( -                'reaction_add', -                check=partial(self._reaction_check, author, message), -                timeout=constants.Sync.confirm_timeout -            ) -        except asyncio.TimeoutError: -            # reaction will remain none thus sync will be aborted in the finally block below. -            log.debug(f"The {self.name} syncer confirmation prompt timed out.") - -        if str(reaction) == constants.Emojis.check_mark: -            log.trace(f"The {self.name} syncer was confirmed.") -            await message.edit(content=f':ok_hand: {mention}{self.name} sync will proceed.') -            return True -        else: -            log.info(f"The {self.name} syncer was aborted or timed out!") -            await message.edit( -                content=f':warning: {mention}{self.name} sync aborted or timed out!' -            ) -            return False -      @abc.abstractmethod      async def _get_diff(self, guild: Guild) -> _Diff:          """Return the difference between the cache of `guild` and the database.""" @@ -153,62 +39,19 @@ class Syncer(abc.ABC):          """Perform the API calls for synchronisation."""          raise NotImplementedError  # pragma: no cover -    async def _get_confirmation_result( -        self, -        diff_size: int, -        author: Member, -        message: t.Optional[Message] = None -    ) -> t.Tuple[bool, t.Optional[Message]]: -        """ -        Prompt for confirmation and return a tuple of the result and the prompt message. - -        `diff_size` is the size of the diff of the sync. If it is greater than -        `bot.constants.Sync.max_diff`, the prompt will be sent. The `author` is the invoked of the -        sync and the `message` is an extant message to edit to display the prompt. - -        If confirmed or no confirmation was needed, the result is True. The returned message will -        either be the given `message` or a new one which was created when sending the prompt. -        """ -        log.trace(f"Determining if confirmation prompt should be sent for {self.name} syncer.") -        if diff_size > constants.Sync.max_diff: -            message = await self._send_prompt(message) -            if not message: -                return False, None  # Couldn't get channel. - -            confirmed = await self._wait_for_confirmation(author, message) -            if not confirmed: -                return False, message  # Sync aborted. - -        return True, message -      async def sync(self, guild: Guild, ctx: t.Optional[Context] = None) -> None:          """          Synchronise the database with the cache of `guild`. -        If the differences between the cache and the database are greater than -        `bot.constants.Sync.max_diff`, then a confirmation prompt will be sent to the dev-core -        channel. The confirmation can be optionally redirect to `ctx` instead. +        If `ctx` is given, send a message with the results.          """          log.info(f"Starting {self.name} syncer.") -        message = None -        author = self.bot.user          if ctx:              message = await ctx.send(f"📊 Synchronising {self.name}s.") -            author = ctx.author - +        else: +            message = None          diff = await self._get_diff(guild) -        diff_dict = diff._asdict()  # Ugly method for transforming the NamedTuple into a dict -        totals = {k: len(v) for k, v in diff_dict.items() if v is not None} -        diff_size = sum(totals.values()) - -        confirmed, message = await self._get_confirmation_result(diff_size, author, message) -        if not confirmed: -            return - -        # Preserve the core-dev role mention in the message edits so users aren't confused about -        # where notifications came from. -        mention = self._CORE_DEV_MENTION if author.bot else ""          try:              await self._sync(diff) @@ -217,11 +60,14 @@ class Syncer(abc.ABC):              # Don't show response text because it's probably some really long HTML.              results = f"status {e.status}\n```{e.response_json or 'See log output for details'}```" -            content = f":x: {mention}Synchronisation of {self.name}s failed: {results}" +            content = f":x: Synchronisation of {self.name}s failed: {results}"          else: -            results = ", ".join(f"{name} `{total}`" for name, total in totals.items()) +            diff_dict = diff._asdict() +            results = (f"{name} `{len(val)}`" for name, val in diff_dict.items() if val is not None) +            results = ", ".join(results) +              log.info(f"{self.name} syncer finished: {results}.") -            content = f":ok_hand: {mention}Synchronisation of {self.name}s complete: {results}" +            content = f":ok_hand: Synchronisation of {self.name}s complete: {results}"          if message:              await message.edit(content=content) @@ -287,61 +133,76 @@ class UserSyncer(Syncer):      async def _get_diff(self, guild: Guild) -> _Diff:          """Return the difference of users between the cache of `guild` and the database."""          log.trace("Getting the diff for users.") -        users = await self.bot.api_client.get('bot/users') -        # Pack DB roles and guild roles into one common, hashable format. -        # They're hashable so that they're easily comparable with sets later. -        db_users = { -            user_dict['id']: _User( -                roles=tuple(sorted(user_dict.pop('roles'))), -                **user_dict -            ) -            for user_dict in users -        } -        guild_users = { -            member.id: _User( -                id=member.id, -                name=member.name, -                discriminator=int(member.discriminator), -                roles=tuple(sorted(role.id for role in member.roles)), -                in_guild=True -            ) -            for member in guild.members -        } +        users_to_create = [] +        users_to_update = [] +        seen_guild_users = set() + +        async for db_user in self._get_users(): +            # Store user fields which are to be updated. +            updated_fields = {} + +            def maybe_update(db_field: str, guild_value: t.Union[str, int]) -> None: +                # Equalize DB user and guild user attributes. +                if db_user[db_field] != guild_value: +                    updated_fields[db_field] = guild_value -        users_to_create = set() -        users_to_update = set() +            if guild_user := guild.get_member(db_user["id"]): +                seen_guild_users.add(guild_user.id) -        for db_user in db_users.values(): -            guild_user = guild_users.get(db_user.id) -            if guild_user is not None: -                if db_user != guild_user: -                    users_to_update.add(guild_user) +                maybe_update("name", guild_user.name) +                maybe_update("discriminator", int(guild_user.discriminator)) +                maybe_update("in_guild", True) -            elif db_user.in_guild: +                guild_roles = [role.id for role in guild_user.roles] +                if set(db_user["roles"]) != set(guild_roles): +                    updated_fields["roles"] = guild_roles + +            elif db_user["in_guild"]:                  # The user is known in the DB but not the guild, and the                  # DB currently specifies that the user is a member of the guild.                  # This means that the user has left since the last sync.                  # Update the `in_guild` attribute of the user on the site                  # to signify that the user left. -                new_api_user = db_user._replace(in_guild=False) -                users_to_update.add(new_api_user) - -        new_user_ids = set(guild_users.keys()) - set(db_users.keys()) -        for user_id in new_user_ids: -            # The user is known on the guild but not on the API. This means -            # that the user has joined since the last sync. Create it. -            new_user = guild_users[user_id] -            users_to_create.add(new_user) +                updated_fields["in_guild"] = False + +            if updated_fields: +                updated_fields["id"] = db_user["id"] +                users_to_update.append(updated_fields) + +        for member in guild.members: +            if member.id not in seen_guild_users: +                # The user is known on the guild but not on the API. This means +                # that the user has joined since the last sync. Create it. +                new_user = { +                    "id": member.id, +                    "name": member.name, +                    "discriminator": int(member.discriminator), +                    "roles": [role.id for role in member.roles], +                    "in_guild": True +                } +                users_to_create.append(new_user)          return _Diff(users_to_create, users_to_update, None) +    async def _get_users(self) -> t.AsyncIterable: +        """GET users from database.""" +        query_params = { +            "page": 1 +        } +        while query_params["page"]: +            res = await self.bot.api_client.get("bot/users", params=query_params) +            for user in res["results"]: +                yield user + +            query_params["page"] = res["next_page_no"] +      async def _sync(self, diff: _Diff) -> None:          """Synchronise the database with the user cache of `guild`."""          log.trace("Syncing created users...") -        for user in diff.created: -            await self.bot.api_client.post('bot/users', json=user._asdict()) +        if diff.created: +            await self.bot.api_client.post("bot/users", json=diff.created)          log.trace("Syncing updated users...") -        for user in diff.updated: -            await self.bot.api_client.put(f'bot/users/{user.id}', json=user._asdict()) +        if diff.updated: +            await self.bot.api_client.patch("bot/users/bulk_patch", json=diff.updated) diff --git a/bot/exts/filters/antimalware.py b/bot/exts/filters/antimalware.py index 7894ec48f..26f00e91f 100644 --- a/bot/exts/filters/antimalware.py +++ b/bot/exts/filters/antimalware.py @@ -6,7 +6,7 @@ from discord import Embed, Message, NotFound  from discord.ext.commands import Cog  from bot.bot import Bot -from bot.constants import Channels, STAFF_ROLES, URLs +from bot.constants import Channels, Filter, URLs  log = logging.getLogger(__name__) @@ -61,7 +61,7 @@ class AntiMalware(Cog):          # Check if user is staff, if is, return          # Since we only care that roles exist to iterate over, check for the attr rather than a User/Member instance -        if hasattr(message.author, "roles") and any(role.id in STAFF_ROLES for role in message.author.roles): +        if hasattr(message.author, "roles") and any(role.id in Filter.role_whitelist for role in message.author.roles):              return          embed = Embed() diff --git a/bot/exts/filters/antispam.py b/bot/exts/filters/antispam.py index 2e7e32d9a..af8528a68 100644 --- a/bot/exts/filters/antispam.py +++ b/bot/exts/filters/antispam.py @@ -15,11 +15,10 @@ from bot.constants import (      AntiSpam as AntiSpamConfig, Channels,      Colours, DEBUG_MODE, Event, Filter,      Guild as GuildConfig, Icons, -    STAFF_ROLES,  )  from bot.converters import Duration  from bot.exts.moderation.modlog import ModLog -from bot.utils.messages import send_attachments +from bot.utils.messages import format_user, send_attachments  log = logging.getLogger(__name__) @@ -36,9 +35,6 @@ RULE_FUNCTION_MAPPING = {      'mentions': rules.apply_mentions,      'newlines': rules.apply_newlines,      'role_mentions': rules.apply_role_mentions, -    # the everyone filter is temporarily disabled until -    # it has been improved. -    # 'everyone_ping': rules.apply_everyone_ping,  } @@ -71,7 +67,7 @@ class DeletionContext:      async def upload_messages(self, actor_id: int, modlog: ModLog) -> None:          """Method that takes care of uploading the queue and posting modlog alert.""" -        triggered_by_users = ", ".join(f"{m} (`{m.id}`)" for m in self.members.values()) +        triggered_by_users = ", ".join(format_user(m) for m in self.members.values())          mod_alert_message = (              f"**Triggered by:** {triggered_by_users}\n" @@ -152,7 +148,7 @@ class AntiSpam(Cog):              or message.guild.id != GuildConfig.id              or message.author.bot              or (message.channel.id in Filter.channel_whitelist and not DEBUG_MODE) -            or (any(role.id in STAFF_ROLES for role in message.author.roles) and not DEBUG_MODE) +            or (any(role.id in Filter.role_whitelist for role in message.author.roles) and not DEBUG_MODE)          ):              return diff --git a/bot/exts/filters/filter_lists.py b/bot/exts/filters/filter_lists.py index c15adc461..232c1e48b 100644 --- a/bot/exts/filters/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/exts/filters/filtering.py b/bot/exts/filters/filtering.py index 2751ed7f6..92cdfb8f5 100644 --- a/bot/exts/filters/filtering.py +++ b/bot/exts/filters/filtering.py @@ -2,10 +2,11 @@ import asyncio  import logging  import re  from datetime import datetime, timedelta -from typing import List, Mapping, Optional, Tuple, Union +from typing import Any, Dict, List, Mapping, NamedTuple, Optional, Union  import dateutil  import discord.errors +from async_rediscache import RedisCache  from dateutil.relativedelta import relativedelta  from discord import Colour, HTTPException, Member, Message, NotFound, TextChannel  from discord.ext.commands import Cog @@ -14,17 +15,23 @@ from discord.utils import escape_markdown  from bot.api import ResponseCodeError  from bot.bot import Bot  from bot.constants import ( -    Channels, Colours, -    Filter, Icons, URLs +    Channels, Colours, Filter, +    Guild, Icons, URLs  )  from bot.exts.moderation.modlog import ModLog -from bot.utils.redis_cache import RedisCache +from bot.utils.messages import format_user  from bot.utils.regex import INVITE_RE  from bot.utils.scheduling import Scheduler  log = logging.getLogger(__name__)  # Regular expressions +CODE_BLOCK_RE = re.compile( +    r"(?P<delim>``?)[^`]+?(?P=delim)(?!`+)"  # Inline codeblock +    r"|```(.+?)```",  # Multiline codeblock +    re.DOTALL | re.MULTILINE +) +EVERYONE_PING_RE = re.compile(rf"@everyone|<@&{Guild.id}>|@here")  SPOILER_RE = re.compile(r"(\|\|.+?\|\|)", re.DOTALL)  URL_RE = re.compile(r"(https?://[^\s]+)", flags=re.IGNORECASE)  ZALGO_RE = re.compile(r"[\u0300-\u036F\u0489]") @@ -33,6 +40,16 @@ ZALGO_RE = re.compile(r"[\u0300-\u036F\u0489]")  DAYS_BETWEEN_ALERTS = 3  OFFENSIVE_MSG_DELETE_TIME = timedelta(days=Filter.offensive_msg_delete_days) +FilterMatch = Union[re.Match, dict, bool, List[discord.Embed]] + + +class Stats(NamedTuple): +    """Additional stats on a triggered filter to append to a mod log.""" + +    message_content: str +    additional_embeds: Optional[List[discord.Embed]] +    additional_embeds_msg: Optional[str] +  class Filtering(Cog):      """Filtering out invites, blacklisting domains, and warning us of certain regular expressions.""" @@ -82,6 +99,19 @@ class Filtering(Cog):                  ),                  "schedule_deletion": False              }, +            "filter_everyone_ping": { +                "enabled": Filter.filter_everyone_ping, +                "function": self._has_everyone_ping, +                "type": "filter", +                "content_only": True, +                "user_notification": Filter.notify_user_everyone_ping, +                "notification_msg": ( +                    "Please don't try to ping `@everyone` or `@here`. " +                    f"Your message has been removed. {staff_mistake_str}" +                ), +                "schedule_deletion": False, +                "ping_everyone": False +            },              "watch_regex": {                  "enabled": Filter.watch_regex,                  "function": self._has_watch_regex_match, @@ -175,8 +205,8 @@ class Filtering(Cog):              log.info(f"Sending bad nickname alert for '{member.display_name}' ({member.id}).")              log_string = ( -                f"**User:** {member.mention} (`{member.id}`)\n" -                f"**Display Name:** {member.display_name}\n" +                f"**User:** {format_user(member)}\n" +                f"**Display Name:** {escape_markdown(member.display_name)}\n"                  f"**Bad Matches:** {', '.join(match.group() for match in matches)}"              ) @@ -215,35 +245,8 @@ class Filtering(Cog):                          if _filter["type"] == "filter":                              filter_triggered = True -                        # We do not have to check against DM channels since !eval cannot be used there. -                        channel_str = f"in {msg.channel.mention}" - -                        message_content, additional_embeds, additional_embeds_msg = self._add_stats( -                            filter_name, match, result -                        ) - -                        message = ( -                            f"The {filter_name} {_filter['type']} was triggered " -                            f"by **{msg.author}** " -                            f"(`{msg.author.id}`) {channel_str} using !eval with " -                            f"[the following message]({msg.jump_url}):\n\n" -                            f"{message_content}" -                        ) - -                        log.debug(message) - -                        # Send pretty mod log embed to mod-alerts -                        await self.mod_log.send_log_message( -                            icon_url=Icons.filtering, -                            colour=Colour(Colours.soft_red), -                            title=f"{_filter['type'].title()} triggered!", -                            text=message, -                            thumbnail=msg.author.avatar_url_as(static_format="png"), -                            channel_id=Channels.mod_alerts, -                            ping_everyone=Filter.ping_everyone, -                            additional_embeds=additional_embeds, -                            additional_embeds_msg=additional_embeds_msg -                        ) +                        stats = self._add_stats(filter_name, match, result) +                        await self._send_log(filter_name, _filter["type"], msg, stats, is_eval=True)                          break  # We don't want multiple filters to trigger @@ -313,43 +316,52 @@ class Filtering(Cog):                                  self.schedule_msg_delete(data)                                  log.trace(f"Offensive message {msg.id} will be deleted on {delete_date}") -                        if is_private: -                            channel_str = "via DM" -                        else: -                            channel_str = f"in {msg.channel.mention}" - -                        message_content, additional_embeds, additional_embeds_msg = self._add_stats( -                            filter_name, match, msg.content -                        ) +                        stats = self._add_stats(filter_name, match, msg.content) +                        await self._send_log(filter_name, _filter, msg, stats) -                        message = ( -                            f"The {filter_name} {_filter['type']} was triggered " -                            f"by **{msg.author}** " -                            f"(`{msg.author.id}`) {channel_str} with [the " -                            f"following message]({msg.jump_url}):\n\n" -                            f"{message_content}" -                        ) +                        break  # We don't want multiple filters to trigger -                        log.debug(message) - -                        # Send pretty mod log embed to mod-alerts -                        await self.mod_log.send_log_message( -                            icon_url=Icons.filtering, -                            colour=Colour(Colours.soft_red), -                            title=f"{_filter['type'].title()} triggered!", -                            text=message, -                            thumbnail=msg.author.avatar_url_as(static_format="png"), -                            channel_id=Channels.mod_alerts, -                            ping_everyone=Filter.ping_everyone if not is_private else False, -                            additional_embeds=additional_embeds, -                            additional_embeds_msg=additional_embeds_msg -                        ) +    async def _send_log( +        self, +        filter_name: str, +        _filter: Dict[str, Any], +        msg: discord.Message, +        stats: Stats, +        *, +        is_eval: bool = False, +    ) -> None: +        """Send a mod log for a triggered filter.""" +        if msg.channel.type is discord.ChannelType.private: +            channel_str = "via DM" +            ping_everyone = False +        else: +            channel_str = f"in {msg.channel.mention}" +            # Allow specific filters to override ping_everyone +            ping_everyone = Filter.ping_everyone and _filter.get("ping_everyone", True) + +        eval_msg = "using !eval " if is_eval else "" +        message = ( +            f"The {filter_name} {_filter['type']} was triggered by {format_user(msg.author)} " +            f"{channel_str} {eval_msg}with [the following message]({msg.jump_url}):\n\n" +            f"{stats.message_content}" +        ) -                        break  # We don't want multiple filters to trigger +        log.debug(message) + +        # Send pretty mod log embed to mod-alerts +        await self.mod_log.send_log_message( +            icon_url=Icons.filtering, +            colour=Colour(Colours.soft_red), +            title=f"{_filter['type'].title()} triggered!", +            text=message, +            thumbnail=msg.author.avatar_url_as(static_format="png"), +            channel_id=Channels.mod_alerts, +            ping_everyone=ping_everyone, +            additional_embeds=stats.additional_embeds, +            additional_embeds_msg=stats.additional_embeds_msg +        ) -    def _add_stats(self, name: str, match: Union[re.Match, dict, bool, List[discord.Embed]], content: str) -> Tuple[ -        str, Optional[List[discord.Embed]], Optional[str] -    ]: +    def _add_stats(self, name: str, match: FilterMatch, content: str) -> Stats:          """Adds relevant statistical information to the relevant filter and increments the bot's stats."""          # Word and match stats for watch_regex          if name == "watch_regex": @@ -386,7 +398,7 @@ class Filtering(Cog):              additional_embeds = match              additional_embeds_msg = "With the following embed(s):" -        return message_content, additional_embeds, additional_embeds_msg +        return Stats(message_content, additional_embeds, additional_embeds_msg)      @staticmethod      def _check_filter(msg: Message) -> bool: @@ -528,6 +540,16 @@ class Filtering(Cog):                          return False          return False +    @staticmethod +    async def _has_everyone_ping(text: str) -> bool: +        """Determines if `msg` contains an @everyone or @here ping outside of a codeblock.""" +        # First pass to avoid running re.sub on every message +        if not EVERYONE_PING_RE.search(text): +            return False + +        content_without_codeblocks = CODE_BLOCK_RE.sub("", text) +        return bool(EVERYONE_PING_RE.search(content_without_codeblocks)) +      async def notify_member(self, filtered_member: Member, reason: str, channel: TextChannel) -> None:          """          Notify filtered_member about a moderation action with the reason str. diff --git a/bot/exts/filters/token_remover.py b/bot/exts/filters/token_remover.py index 0eda3dc6a..bd6a1f97a 100644 --- a/bot/exts/filters/token_remover.py +++ b/bot/exts/filters/token_remover.py @@ -11,13 +11,19 @@ from bot import utils  from bot.bot import Bot  from bot.constants import Channels, Colours, Event, Icons  from bot.exts.moderation.modlog import ModLog +from bot.utils.messages import format_user  log = logging.getLogger(__name__)  LOG_MESSAGE = ( -    "Censored a seemingly valid token sent by {author} (`{author_id}`) in {channel}, " +    "Censored a seemingly valid token sent by {author} in {channel}, "      "token was `{user_id}.{timestamp}.{hmac}`"  ) +UNKNOWN_USER_LOG_MESSAGE = "Decoded user ID: `{user_id}` (Not present in server)." +KNOWN_USER_LOG_MESSAGE = ( +    "Decoded user ID: `{user_id}` **(Present in server)**.\n" +    "This matches `{user_name}` and means this is likely a valid **{kind}** token." +)  DELETION_MESSAGE_TEMPLATE = (      "Hey {mention}! I noticed you posted a seemingly valid Discord API "      "token in your message and have removed your message. " @@ -93,6 +99,7 @@ class TokenRemover(Cog):          await msg.channel.send(DELETION_MESSAGE_TEMPLATE.format(mention=msg.author.mention))          log_message = self.format_log_message(msg, found_token) +        userid_message, mention_everyone = self.format_userid_log_message(msg, found_token)          log.debug(log_message)          # Send pretty mod log embed to mod-alerts @@ -100,19 +107,43 @@ class TokenRemover(Cog):              icon_url=Icons.token_removed,              colour=Colour(Colours.soft_red),              title="Token removed!", -            text=log_message, +            text=log_message + "\n" + userid_message,              thumbnail=msg.author.avatar_url_as(static_format="png"),              channel_id=Channels.mod_alerts, +            ping_everyone=mention_everyone,          )          self.bot.stats.incr("tokens.removed_tokens") +    @classmethod +    def format_userid_log_message(cls, msg: Message, token: Token) -> t.Tuple[str, bool]: +        """ +        Format the portion of the log message that includes details about the detected user ID. + +        If the user is resolved to a member, the format includes the user ID, name, and the +        kind of user detected. + +        If we resolve to a member and it is not a bot, we also return True to ping everyone. + +        Returns a tuple of (log_message, mention_everyone) +        """ +        user_id = cls.extract_user_id(token.user_id) +        user = msg.guild.get_member(user_id) + +        if user: +            return KNOWN_USER_LOG_MESSAGE.format( +                user_id=user_id, +                user_name=str(user), +                kind="BOT" if user.bot else "USER", +            ), not user.bot +        else: +            return UNKNOWN_USER_LOG_MESSAGE.format(user_id=user_id), False +      @staticmethod      def format_log_message(msg: Message, token: Token) -> str: -        """Return the log message to send for `token` being censored in `msg`.""" +        """Return the generic portion of the log message to send for `token` being censored in `msg`."""          return LOG_MESSAGE.format( -            author=msg.author, -            author_id=msg.author.id, +            author=format_user(msg.author),              channel=msg.channel.mention,              user_id=token.user_id,              timestamp=token.timestamp, @@ -126,7 +157,11 @@ class TokenRemover(Cog):          # token check (e.g. `message.channel.send` also matches our token pattern)          for match in TOKEN_RE.finditer(msg.content):              token = Token(*match.groups()) -            if cls.is_valid_user_id(token.user_id) and cls.is_valid_timestamp(token.timestamp): +            if ( +                (cls.extract_user_id(token.user_id) is not None) +                and cls.is_valid_timestamp(token.timestamp) +                and cls.is_maybe_valid_hmac(token.hmac) +            ):                  # Short-circuit on first match                  return token @@ -134,22 +169,20 @@ class TokenRemover(Cog):          return      @staticmethod -    def is_valid_user_id(b64_content: str) -> bool: -        """ -        Check potential token to see if it contains a valid Discord user ID. - -        See: https://discordapp.com/developers/docs/reference#snowflakes -        """ +    def extract_user_id(b64_content: str) -> t.Optional[int]: +        """Return a user ID integer from part of a potential token, or None if it couldn't be decoded."""          b64_content = utils.pad_base64(b64_content)          try:              decoded_bytes = base64.urlsafe_b64decode(b64_content)              string = decoded_bytes.decode('utf-8') - -            # isdigit on its own would match a lot of other Unicode characters, hence the isascii. -            return string.isascii() and string.isdigit() +            if not (string.isascii() and string.isdigit()): +                # This case triggers if there are fancy unicode digits in the base64 encoding, +                # that means it's not a valid user id. +                return None +            return int(string)          except (binascii.Error, ValueError): -            return False +            return None      @staticmethod      def is_valid_timestamp(b64_content: str) -> bool: @@ -176,6 +209,24 @@ class TokenRemover(Cog):              log.debug(f"Invalid token timestamp '{b64_content}': smaller than Discord epoch")              return False +    @staticmethod +    def is_maybe_valid_hmac(b64_content: str) -> bool: +        """ +        Determine if a given HMAC portion of a token is potentially valid. + +        If the HMAC has 3 or less characters, it's probably a dummy value like "xxxxxxxxxx", +        and thus the token can probably be skipped. +        """ +        unique = len(set(b64_content.lower())) +        if unique <= 3: +            log.debug( +                f"Considering the HMAC {b64_content} a dummy because it has {unique}" +                " case-insensitively unique characters" +            ) +            return False +        else: +            return True +  def setup(bot: Bot) -> None:      """Load the TokenRemover cog.""" diff --git a/bot/exts/filters/webhook_remover.py b/bot/exts/filters/webhook_remover.py index ca126ebf5..08fe94055 100644 --- a/bot/exts/filters/webhook_remover.py +++ b/bot/exts/filters/webhook_remover.py @@ -7,6 +7,7 @@ from discord.ext.commands import Cog  from bot.bot import Bot  from bot.constants import Channels, Colours, Event, Icons  from bot.exts.moderation.modlog import ModLog +from bot.utils.messages import format_user  WEBHOOK_URL_RE = re.compile(r"((?:https?://)?discord(?:app)?\.com/api/webhooks/\d+/)\S+/?", re.IGNORECASE) @@ -45,8 +46,8 @@ class WebhookRemover(Cog):          await msg.channel.send(ALERT_MESSAGE_TEMPLATE.format(user=msg.author.mention))          message = ( -            f"{msg.author} (`{msg.author.id}`) posted a Discord webhook URL " -            f"to #{msg.channel}. Webhook URL was `{redacted_url}`" +            f"{format_user(msg.author)} posted a Discord webhook URL to {msg.channel.mention}. " +            f"Webhook URL was `{redacted_url}`"          )          log.debug(message) diff --git a/bot/exts/fun/duck_pond.py b/bot/exts/fun/duck_pond.py index 7021069fa..48aa2749c 100644 --- a/bot/exts/fun/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 @@ -20,7 +22,9 @@ class DuckPond(Cog):          self.bot = bot          self.webhook_id = constants.Webhooks.duck_pond          self.webhook = None +        self.ducked_messages = []          self.bot.loop.create_task(self.fetch_webhook()) +        self.relay_lock = None      async def fetch_webhook(self) -> None:          """Fetches the webhook object, so we can post to it.""" @@ -49,32 +53,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 +107,35 @@ class DuckPond(Cog):              except discord.HTTPException:                  log.exception("Failed to send an attachment to the webhook") -        await message.add_reaction("✅") +    async def locked_relay(self, message: discord.Message) -> bool: +        """Relay a message after obtaining the relay lock.""" +        if self.relay_lock is None: +            # Lazily load the lock to ensure it's created within the +            # appropriate event loop. +            self.relay_lock = asyncio.Lock() -    @staticmethod -    def _payload_has_duckpond_emoji(payload: RawReactionActionEvent) -> bool: +        async with self.relay_lock: +            # check if the message has a checkmark after acquiring the lock +            if await self.has_green_checkmark(message): +                return False + +            # relay the message +            await self.relay_message(message) + +            # add a green checkmark to indicate that the message was relayed +            await message.add_reaction("✅") +        return True + +    def _payload_has_duckpond_emoji(self, emoji: discord.PartialEmoji) -> bool:          """Test if the RawReactionActionEvent payload contains a duckpond emoji.""" -        if payload.emoji.is_custom_emoji(): -            if payload.emoji.id in constants.DuckPond.custom_emojis: -                return True -        elif payload.emoji.name == "🦆": -            return True +        if emoji.is_unicode_emoji(): +            # For unicode PartialEmojis, the `name` attribute is just the string +            # representation of the emoji. This is what the helper method +            # expects, as unicode emojis show up as just a `str` instance when +            # inspecting the reactions attached to a message. +            emoji = emoji.name -        return False +        return self._is_duck_emoji(emoji)      @Cog.listener()      async def on_raw_reaction_add(self, payload: RawReactionActionEvent) -> None: @@ -125,33 +146,51 @@ class DuckPond(Cog):          amount of ducks specified in the config under duck_pond/threshold, it will          send the message off to the duck pond.          """ +        # Ignore other guilds and DMs. +        if payload.guild_id != constants.Guild.id: +            return + +        # Was this reaction issued in a blacklisted channel? +        if payload.channel_id in constants.DuckPond.channel_blacklist: +            return +          # Is the emoji in the reaction a duck? -        if not self._payload_has_duckpond_emoji(payload): +        if not self._payload_has_duckpond_emoji(payload.emoji):              return          channel = discord.utils.get(self.bot.get_all_channels(), id=payload.channel_id) +        if channel is None: +            return +          message = await channel.fetch_message(payload.message_id)          member = discord.utils.get(message.guild.members, id=payload.user_id) -        # Is the member a human and a staff member? -        if not self.is_staff(member) or member.bot: +        # Was the message sent by a human staff member? +        if not self.is_staff(message.author) or message.author.bot:              return -        # Does the message already have a green checkmark? -        if await self.has_green_checkmark(message): +        # Is the reactor a human staff member? +        if not self.is_staff(member) or member.bot:              return          # Time to count our ducks!          duck_count = await self.count_ducks(message)          # If we've got more than the required amount of ducks, send the message to the duck_pond. -        if duck_count >= constants.DuckPond.threshold: -            await self.relay_message(message) +        if duck_count >= constants.DuckPond.threshold and message.id not in self.ducked_messages: +            self.ducked_messages.append(message.id) +            await self.locked_relay(message)      @Cog.listener()      async def on_raw_reaction_remove(self, payload: RawReactionActionEvent) -> None:          """Ensure that people don't remove the green checkmark from duck ponded messages.""" +        # Ignore other guilds and DMs. +        if payload.guild_id != constants.Guild.id: +            return +          channel = discord.utils.get(self.bot.get_all_channels(), id=payload.channel_id) +        if channel is None: +            return          # Prevent the green checkmark from being removed          if payload.emoji.name == "✅": @@ -160,6 +199,15 @@ class DuckPond(Cog):              if duck_count >= constants.DuckPond.threshold:                  await message.add_reaction("✅") +    @command(name="duckify", aliases=("duckpond", "pondify")) +    @has_any_role(constants.Roles.admins) +    async def duckify(self, ctx: Context, message: discord.Message) -> None: +        """Relay a message to the duckpond, no ducks required!""" +        if await self.locked_relay(message): +            await ctx.message.add_reaction("🦆") +        else: +            await ctx.message.add_reaction("❌") +  def setup(bot: Bot) -> None:      """Load the DuckPond cog.""" diff --git a/bot/exts/fun/off_topic_names.py b/bot/exts/fun/off_topic_names.py index ce95450e0..7fc93b88c 100644 --- a/bot/exts/fun/off_topic_names.py +++ b/bot/exts/fun/off_topic_names.py @@ -1,16 +1,15 @@ -import asyncio  import difflib  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 discord.utils import sleep_until  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) @@ -24,8 +23,7 @@ async def update_names(bot: Bot) -> None:          # we go past midnight in the `seconds_to_sleep` set below.          today_at_midnight = datetime.utcnow().replace(microsecond=0, second=0, minute=0, hour=0)          next_midnight = today_at_midnight + timedelta(days=1) -        seconds_to_sleep = (next_midnight - datetime.utcnow()).seconds + 1 -        await asyncio.sleep(seconds_to_sleep) +        await sleep_until(next_midnight)          try:              channel_0_name, channel_1_name, channel_2_name = await bot.api_client.get( @@ -67,13 +65,13 @@ class OffTopicNames(Cog):              self.updater_task = self.bot.loop.create_task(coro)      @group(name='otname', aliases=('otnames', 'otn'), invoke_without_command=True) -    @with_role(*MODERATION_ROLES) +    @has_any_role(*MODERATION_ROLES)      async def otname_group(self, ctx: Context) -> None:          """Add or list items from the off-topic channel name rotation."""          await ctx.send_help(ctx.command)      @otname_group.command(name='add', aliases=('a',)) -    @with_role(*MODERATION_ROLES) +    @has_any_role(*MODERATION_ROLES)      async def add_command(self, ctx: Context, *, name: OffTopicName) -> None:          """          Adds a new off-topic name to the rotation. @@ -96,7 +94,7 @@ class OffTopicNames(Cog):              await self._add_name(ctx, name)      @otname_group.command(name='forceadd', aliases=('fa',)) -    @with_role(*MODERATION_ROLES) +    @has_any_role(*MODERATION_ROLES)      async def force_add_command(self, ctx: Context, *, name: OffTopicName) -> None:          """Forcefully adds a new off-topic name to the rotation."""          await self._add_name(ctx, name) @@ -109,7 +107,7 @@ class OffTopicNames(Cog):          await ctx.send(f":ok_hand: Added `{name}` to the names list.")      @otname_group.command(name='delete', aliases=('remove', 'rm', 'del', 'd')) -    @with_role(*MODERATION_ROLES) +    @has_any_role(*MODERATION_ROLES)      async def delete_command(self, ctx: Context, *, name: OffTopicName) -> None:          """Removes a off-topic name from the rotation."""          await self.bot.api_client.delete(f'bot/off-topic-channel-names/{name}') @@ -118,7 +116,7 @@ class OffTopicNames(Cog):          await ctx.send(f":ok_hand: Removed `{name}` from the names list.")      @otname_group.command(name='list', aliases=('l',)) -    @with_role(*MODERATION_ROLES) +    @has_any_role(*MODERATION_ROLES)      async def list_command(self, ctx: Context) -> None:          """          Lists all currently known off-topic channel names in a paginator. @@ -138,7 +136,7 @@ class OffTopicNames(Cog):              await ctx.send(embed=embed)      @otname_group.command(name='search', aliases=('s',)) -    @with_role(*MODERATION_ROLES) +    @has_any_role(*MODERATION_ROLES)      async def search_command(self, ctx: Context, *, query: OffTopicName) -> None:          """Search for an off-topic name."""          result = await self.bot.api_client.get('bot/off-topic-channel-names') diff --git a/bot/exts/help_channels.py b/bot/exts/help_channels.py index 0f9cac89e..062d4fcfe 100644 --- a/bot/exts/help_channels.py +++ b/bot/exts/help_channels.py @@ -9,12 +9,12 @@ from pathlib import Path  import discord  import discord.abc +from async_rediscache import RedisCache  from discord.ext import commands  from bot import constants  from bot.bot import Bot -from bot.utils import RedisCache -from bot.utils.checks import with_role_check +from bot.utils import channel as channel_utils  from bot.utils.scheduling import Scheduler  log = logging.getLogger(__name__) @@ -196,12 +196,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: @@ -379,11 +379,18 @@ class HelpChannels(commands.Cog):          log.trace("Getting the CategoryChannel objects for the help categories.")          try: -            self.available_category = await self.try_get_channel( -                constants.Categories.help_available +            self.available_category = await channel_utils.try_get_channel( +                constants.Categories.help_available, +                self.bot +            ) +            self.in_use_category = await channel_utils.try_get_channel( +                constants.Categories.help_in_use, +                self.bot +            ) +            self.dormant_category = await channel_utils.try_get_channel( +                constants.Categories.help_dormant, +                self.bot              ) -            self.in_use_category = await self.try_get_channel(constants.Categories.help_in_use) -            self.dormant_category = await self.try_get_channel(constants.Categories.help_dormant)          except discord.HTTPException:              log.exception("Failed to get a category; cog will be removed")              self.bot.remove_cog(self.qualified_name) @@ -443,12 +450,6 @@ class HelpChannels(commands.Cog):              return False          return message.author == self.bot.user and bot_msg_desc.strip() == description.strip() -    @staticmethod -    def is_in_category(channel: discord.TextChannel, category_id: int) -> bool: -        """Return True if `channel` is within a category with `category_id`.""" -        actual_category = getattr(channel, "category", None) -        return actual_category is not None and actual_category.id == category_id -      async def move_idle_channel(self, channel: discord.TextChannel, has_task: bool = True) -> None:          """          Make the `channel` dormant if idle or schedule the move if still active. @@ -495,11 +496,11 @@ class HelpChannels(commands.Cog):          If `options` are provided, the channel will be edited after the move is completed. This is the          same order of operations that `discord.TextChannel.edit` uses. For information on available -        options, see the documention on `discord.TextChannel.edit`. While possible, position-related +        options, see the documentation on `discord.TextChannel.edit`. While possible, position-related          options should be avoided, as it may interfere with the category move we perform.          """          # Get a fresh copy of the category from the bot to avoid the cache mismatch issue we had. -        category = await self.try_get_channel(category_id) +        category = await channel_utils.try_get_channel(category_id, self.bot)          payload = [{"id": c.id, "position": c.position} for c in category.channels] @@ -647,7 +648,7 @@ class HelpChannels(commands.Cog):          channel = message.channel          # Confirm the channel is an in use help channel -        if self.is_in_category(channel, constants.Categories.help_in_use): +        if channel_utils.is_in_category(channel, constants.Categories.help_in_use):              log.trace(f"Checking if #{channel} ({channel.id}) has been answered.")              # Check if there is an entry in unanswered @@ -672,7 +673,8 @@ class HelpChannels(commands.Cog):          await self.check_for_answer(message) -        if not self.is_in_category(channel, constants.Categories.help_available) or self.is_excluded_channel(channel): +        is_available = channel_utils.is_in_category(channel, constants.Categories.help_available) +        if not is_available or self.is_excluded_channel(channel):              return  # Ignore messages outside the Available category or in excluded channels.          log.trace("Waiting for the cog to be ready before processing messages.") @@ -682,7 +684,7 @@ class HelpChannels(commands.Cog):          async with self.on_message_lock:              log.trace(f"on_message lock acquired for {message.id}.") -            if not self.is_in_category(channel, constants.Categories.help_available): +            if not channel_utils.is_in_category(channel, constants.Categories.help_available):                  log.debug(                      f"Message {message.id} will not make #{channel} ({channel.id}) in-use "                      f"because another message in the channel already triggered that." @@ -720,7 +722,7 @@ class HelpChannels(commands.Cog):          The new time for the dormant task is configured with `HelpChannels.deleted_idle_minutes`.          """ -        if not self.is_in_category(msg.channel, constants.Categories.help_in_use): +        if not channel_utils.is_in_category(msg.channel, constants.Categories.help_in_use):              return          if not await self.is_empty(msg.channel): @@ -845,18 +847,6 @@ class HelpChannels(commands.Cog):              log.trace(f"Dormant message not found in {channel_info}; sending a new message.")              await channel.send(embed=embed) -    async def try_get_channel(self, channel_id: int) -> discord.abc.GuildChannel: -        """Attempt to get or fetch a channel and return it.""" -        log.trace(f"Getting the channel {channel_id}.") - -        channel = self.bot.get_channel(channel_id) -        if not channel: -            log.debug(f"Channel {channel_id} is not in cache; fetching from API.") -            channel = await self.bot.fetch_channel(channel_id) - -        log.trace(f"Channel #{channel} ({channel_id}) retrieved.") -        return channel -      async def pin_wrapper(self, msg_id: int, channel: discord.TextChannel, *, pin: bool) -> bool:          """          Pin message `msg_id` in `channel` if `pin` is True or unpin if it's False. diff --git a/bot/exts/info/codeblock/__init__.py b/bot/exts/info/codeblock/__init__.py new file mode 100644 index 000000000..5c55bc5e3 --- /dev/null +++ b/bot/exts/info/codeblock/__init__.py @@ -0,0 +1,8 @@ +from bot.bot import Bot + + +def setup(bot: Bot) -> None: +    """Load the CodeBlockCog cog.""" +    # Defer import to reduce side effects from importing the codeblock package. +    from bot.exts.info.codeblock._cog import CodeBlockCog +    bot.add_cog(CodeBlockCog(bot)) diff --git a/bot/exts/info/codeblock/_cog.py b/bot/exts/info/codeblock/_cog.py new file mode 100644 index 000000000..1e0feab0d --- /dev/null +++ b/bot/exts/info/codeblock/_cog.py @@ -0,0 +1,186 @@ +import logging +import time +from typing import Optional + +import discord +from discord import Message, RawMessageUpdateEvent +from discord.ext.commands import Cog + +from bot import constants +from bot.bot import Bot +from bot.exts.filters.token_remover import TokenRemover +from bot.exts.filters.webhook_remover import WEBHOOK_URL_RE +from bot.exts.info.codeblock._instructions import get_instructions +from bot.utils import has_lines +from bot.utils.channel import is_help_channel +from bot.utils.messages import wait_for_deletion + +log = logging.getLogger(__name__) + + +class CodeBlockCog(Cog, name="Code Block"): +    """ +    Detect improperly formatted Markdown code blocks and suggest proper formatting. + +    There are four basic ways in which a code block is considered improperly formatted: + +    1. The code is not within a code block at all +        * Ignored if the code is not valid Python or Python REPL code +    2. Incorrect characters are used for backticks +    3. A language for syntax highlighting is not specified +        * Ignored if the code is not valid Python or Python REPL code +    4. A syntax highlighting language is incorrectly specified +        * Ignored if the language specified doesn't look like it was meant for Python +        * This can go wrong in two ways: +            1. Spaces before the language +            2. No newline immediately following the language + +    Messages or code blocks must meet a minimum line count to be detected. Detecting multiple code +    blocks is supported. However, if at least one code block is correct, then instructions will not +    be sent even if others are incorrect. When multiple incorrect code blocks are found, only the +    first one is used as the basis for the instructions sent. + +    When an issue is detected, an embed is sent containing specific instructions on fixing what +    is wrong. If the user edits their message to fix the code block, the instructions will be +    removed. If they fail to fix the code block with an edit, the instructions will be updated to +    show what is still incorrect after the user's edit. The embed can be manually deleted with a +    reaction. Otherwise, it will automatically be removed after 5 minutes. + +    The cog only detects messages in whitelisted channels. Channels may also have a cooldown on the +    instructions being sent. Note all help channels are also whitelisted with cooldowns enabled. + +    For configurable parameters, see the `code_block` section in config-default.py. +    """ + +    def __init__(self, bot: Bot): +        self.bot = bot + +        # Stores allowed channels plus epoch times since the last instructional messages sent. +        self.channel_cooldowns = dict.fromkeys(constants.CodeBlock.cooldown_channels, 0.0) + +        # Maps users' messages to the messages the bot sent with instructions. +        self.codeblock_message_ids = {} + +    @staticmethod +    def create_embed(instructions: str) -> discord.Embed: +        """Return an embed which displays code block formatting `instructions`.""" +        return discord.Embed(description=instructions) + +    async def get_sent_instructions(self, payload: RawMessageUpdateEvent) -> Optional[Message]: +        """ +        Return the bot's sent instructions message associated with a user's message `payload`. + +        Return None if the message cannot be found. In this case, it's likely the message was +        deleted either manually via a reaction or automatically by a timer. +        """ +        log.trace(f"Retrieving instructions message for ID {payload.message_id}") +        channel = self.bot.get_channel(payload.channel_id) + +        try: +            return await channel.fetch_message(self.codeblock_message_ids[payload.message_id]) +        except discord.NotFound: +            log.debug("Could not find instructions message; it was probably deleted.") +            return None + +    def is_on_cooldown(self, channel: discord.TextChannel) -> bool: +        """ +        Return True if an embed was sent too recently for `channel`. + +        The cooldown is configured by `constants.CodeBlock.cooldown_seconds`. +        Note: only channels in the `channel_cooldowns` have cooldowns enabled. +        """ +        log.trace(f"Checking if #{channel} is on cooldown.") +        cooldown = constants.CodeBlock.cooldown_seconds +        return (time.time() - self.channel_cooldowns.get(channel.id, 0)) < cooldown + +    def is_valid_channel(self, channel: discord.TextChannel) -> bool: +        """Return True if `channel` is a help channel, may be on a cooldown, or is whitelisted.""" +        log.trace(f"Checking if #{channel} qualifies for code block detection.") +        return ( +            is_help_channel(channel) +            or channel.id in self.channel_cooldowns +            or channel.id in constants.CodeBlock.channel_whitelist +        ) + +    async def send_instructions(self, message: discord.Message, instructions: str) -> None: +        """ +        Send an embed with `instructions` on fixing an incorrect code block in a `message`. + +        The embed will be deleted automatically after 5 minutes. +        """ +        log.info(f"Sending code block formatting instructions for message {message.id}.") + +        embed = self.create_embed(instructions) +        bot_message = await message.channel.send(f"Hey {message.author.mention}!", embed=embed) +        self.codeblock_message_ids[message.id] = bot_message.id + +        self.bot.loop.create_task(wait_for_deletion(bot_message, (message.author.id,), self.bot)) + +        # Increase amount of codeblock correction in stats +        self.bot.stats.incr("codeblock_corrections") + +    def should_parse(self, message: discord.Message) -> bool: +        """ +        Return True if `message` should be parsed. + +        A qualifying message: + +        1. Is not authored by a bot +        2. Is in a valid channel +        3. Has more than 3 lines +        4. Has no bot or webhook token +        """ +        return ( +            not message.author.bot +            and self.is_valid_channel(message.channel) +            and has_lines(message.content, constants.CodeBlock.minimum_lines) +            and not TokenRemover.find_token_in_message(message) +            and not WEBHOOK_URL_RE.search(message.content) +        ) + +    @Cog.listener() +    async def on_message(self, msg: Message) -> None: +        """Detect incorrect Markdown code blocks in `msg` and send instructions to fix them.""" +        if not self.should_parse(msg): +            log.trace(f"Skipping code block detection of {msg.id}: message doesn't qualify.") +            return + +        # When debugging, ignore cooldowns. +        if self.is_on_cooldown(msg.channel) and not constants.DEBUG_MODE: +            log.trace(f"Skipping code block detection of {msg.id}: #{msg.channel} is on cooldown.") +            return + +        instructions = get_instructions(msg.content) +        if instructions: +            await self.send_instructions(msg, instructions) + +            if msg.channel.id not in constants.CodeBlock.channel_whitelist: +                log.debug(f"Adding #{msg.channel} to the channel cooldowns.") +                self.channel_cooldowns[msg.channel.id] = time.time() + +    @Cog.listener() +    async def on_raw_message_edit(self, payload: RawMessageUpdateEvent) -> None: +        """Delete the instructional message if an edited message had its code blocks fixed.""" +        if payload.message_id not in self.codeblock_message_ids: +            log.trace(f"Ignoring message edit {payload.message_id}: message isn't being tracked.") +            return + +        if payload.data.get("content") is None or payload.data.get("channel_id") is None: +            log.trace(f"Ignoring message edit {payload.message_id}: missing content or channel ID.") +            return + +        # Parse the message to see if the code blocks have been fixed. +        content = payload.data.get("content") +        instructions = get_instructions(content) + +        bot_message = await self.get_sent_instructions(payload) +        if not bot_message: +            return + +        if not instructions: +            log.info("User's incorrect code block has been fixed. Removing instructions message.") +            await bot_message.delete() +            del self.codeblock_message_ids[payload.message_id] +        else: +            log.info("Message edited but still has invalid code blocks; editing the instructions.") +            await bot_message.edit(embed=self.create_embed(instructions)) diff --git a/bot/exts/info/codeblock/_instructions.py b/bot/exts/info/codeblock/_instructions.py new file mode 100644 index 000000000..508f157fb --- /dev/null +++ b/bot/exts/info/codeblock/_instructions.py @@ -0,0 +1,184 @@ +"""This module generates and formats instructional messages about fixing Markdown code blocks.""" + +import logging +from typing import Optional + +from bot.exts.info.codeblock import _parsing + +log = logging.getLogger(__name__) + +_EXAMPLE_PY = "{lang}\nprint('Hello, world!')"  # Make sure to escape any Markdown symbols here. +_EXAMPLE_CODE_BLOCKS = ( +    "\\`\\`\\`{content}\n\\`\\`\\`\n\n" +    "**This will result in the following:**\n" +    "```{content}```" +) + + +def _get_example(language: str) -> str: +    """Return an example of a correct code block using `language` for syntax highlighting.""" +    # Determine the example code to put in the code block based on the language specifier. +    if language.lower() in _parsing.PY_LANG_CODES: +        log.trace(f"Code block has a Python language specifier `{language}`.") +        content = _EXAMPLE_PY.format(lang=language) +    elif language: +        log.trace(f"Code block has a foreign language specifier `{language}`.") +        # It's not feasible to determine what would be a valid example for other languages. +        content = f"{language}\n..." +    else: +        log.trace("Code block has no language specifier.") +        content = "\nHello, world!" + +    return _EXAMPLE_CODE_BLOCKS.format(content=content) + + +def _get_bad_ticks_message(code_block: _parsing.CodeBlock) -> Optional[str]: +    """Return instructions on using the correct ticks for `code_block`.""" +    log.trace("Creating instructions for incorrect code block ticks.") + +    valid_ticks = f"\\{_parsing.BACKTICK}" * 3 +    instructions = ( +        "It looks like you are trying to paste code into this channel.\n\n" +        "You seem to be using the wrong symbols to indicate where the code block should start. " +        f"The correct symbols would be {valid_ticks}, not `{code_block.tick * 3}`." +    ) + +    log.trace("Check if the bad ticks code block also has issues with the language specifier.") +    addition_msg = _get_bad_lang_message(code_block.content) +    if not addition_msg and not code_block.language: +        addition_msg = _get_no_lang_message(code_block.content) + +    # Combine the back ticks message with the language specifier message. The latter will +    # already have an example code block. +    if addition_msg: +        log.trace("Language specifier issue found; appending additional instructions.") + +        # The first line has double newlines which are not desirable when appending the msg. +        addition_msg = addition_msg.replace("\n\n", " ", 1) + +        # Make the first character of the addition lower case. +        instructions += "\n\nFurthermore, " + addition_msg[0].lower() + addition_msg[1:] +    else: +        log.trace("No issues with the language specifier found.") +        example_blocks = _get_example(code_block.language) +        instructions += f"\n\n**Here is an example of how it should look:**\n{example_blocks}" + +    return instructions + + +def _get_no_ticks_message(content: str) -> Optional[str]: +    """If `content` is Python/REPL code, return instructions on using code blocks.""" +    log.trace("Creating instructions for a missing code block.") + +    if _parsing.is_python_code(content): +        example_blocks = _get_example("python") +        return ( +            "It looks like you're trying to paste code into this channel.\n\n" +            "Discord has support for Markdown, which allows you to post code with full " +            "syntax highlighting. Please use these whenever you paste code, as this " +            "helps improve the legibility and makes it easier for us to help you.\n\n" +            f"**To do this, use the following method:**\n{example_blocks}" +        ) +    else: +        log.trace("Aborting missing code block instructions: content is not Python code.") + + +def _get_bad_lang_message(content: str) -> Optional[str]: +    """ +    Return instructions on fixing the Python language specifier for a code block. + +    If `code_block` does not have a Python language specifier, return None. +    If there's nothing wrong with the language specifier, return None. +    """ +    log.trace("Creating instructions for a poorly specified language.") + +    info = _parsing.parse_bad_language(content) +    if not info: +        log.trace("Aborting bad language instructions: language specified isn't Python.") +        return + +    lines = [] +    language = info.language + +    if info.has_leading_spaces: +        log.trace("Language specifier was preceded by a space.") +        lines.append(f"Make sure there are no spaces between the back ticks and `{language}`.") + +    if not info.has_terminal_newline: +        log.trace("Language specifier was not followed by a newline.") +        lines.append( +            f"Make sure you put your code on a new line following `{language}`. " +            f"There must not be any spaces after `{language}`." +        ) + +    if lines: +        lines = " ".join(lines) +        example_blocks = _get_example(language) + +        # Note that _get_bad_ticks_message expects the first line to have two newlines. +        return ( +            f"It looks like you incorrectly specified a language for your code block.\n\n{lines}" +            f"\n\n**Here is an example of how it should look:**\n{example_blocks}" +        ) +    else: +        log.trace("Nothing wrong with the language specifier; no instructions to return.") + + +def _get_no_lang_message(content: str) -> Optional[str]: +    """ +    Return instructions on specifying a language for a code block. + +    If `content` is not valid Python or Python REPL code, return None. +    """ +    log.trace("Creating instructions for a missing language.") + +    if _parsing.is_python_code(content): +        example_blocks = _get_example("python") + +        # Note that _get_bad_ticks_message expects the first line to have two newlines. +        return ( +            "It looks like you pasted Python code without syntax highlighting.\n\n" +            "Please use syntax highlighting to improve the legibility of your code and make " +            "it easier for us to help you.\n\n" +            f"**To do this, use the following method:**\n{example_blocks}" +        ) +    else: +        log.trace("Aborting missing language instructions: content is not Python code.") + + +def get_instructions(content: str) -> Optional[str]: +    """ +    Parse `content` and return code block formatting instructions if something is wrong. + +    Return None if `content` lacks code block formatting issues. +    """ +    log.trace("Getting formatting instructions.") + +    blocks = _parsing.find_code_blocks(content) +    if blocks is None: +        log.trace("At least one valid code block found; no instructions to return.") +        return + +    if not blocks: +        log.trace("No code blocks were found in message.") +        instructions = _get_no_ticks_message(content) +    else: +        log.trace("Searching results for a code block with invalid ticks.") +        block = next((block for block in blocks if block.tick != _parsing.BACKTICK), None) + +        if block: +            log.trace("A code block exists but has invalid ticks.") +            instructions = _get_bad_ticks_message(block) +        else: +            log.trace("A code block exists but is missing a language.") +            block = blocks[0] + +            # Check for a bad language first to avoid parsing content into an AST. +            instructions = _get_bad_lang_message(block.content) +            if not instructions: +                instructions = _get_no_lang_message(block.content) + +    if instructions: +        instructions += "\nYou can **edit your original message** to correct your code block." + +    return instructions diff --git a/bot/exts/info/codeblock/_parsing.py b/bot/exts/info/codeblock/_parsing.py new file mode 100644 index 000000000..a98218dfb --- /dev/null +++ b/bot/exts/info/codeblock/_parsing.py @@ -0,0 +1,228 @@ +"""This module provides functions for parsing Markdown code blocks.""" + +import ast +import logging +import re +import textwrap +from typing import NamedTuple, Optional, Sequence + +from bot import constants +from bot.utils import has_lines + +log = logging.getLogger(__name__) + +BACKTICK = "`" +PY_LANG_CODES = ("python", "pycon", "py")  # Order is important; "py" is last cause it's a subset. +_TICKS = { +    BACKTICK, +    "'", +    '"', +    "\u00b4",  # ACUTE ACCENT +    "\u2018",  # LEFT SINGLE QUOTATION MARK +    "\u2019",  # RIGHT SINGLE QUOTATION MARK +    "\u2032",  # PRIME +    "\u201c",  # LEFT DOUBLE QUOTATION MARK +    "\u201d",  # RIGHT DOUBLE QUOTATION MARK +    "\u2033",  # DOUBLE PRIME +    "\u3003",  # VERTICAL KANA REPEAT MARK UPPER HALF +} + +_RE_PYTHON_REPL = re.compile(r"^(>>>|\.\.\.)( |$)") +_RE_IPYTHON_REPL = re.compile(r"^((In|Out) \[\d+\]: |\s*\.{3,}: ?)") + +_RE_CODE_BLOCK = re.compile( +    fr""" +    (?P<ticks> +        (?P<tick>[{''.join(_TICKS)}]) # Put all ticks into a character class within a group. +        \2{{2}}                       # Match previous group 2 more times to ensure the same char. +    ) +    (?P<lang>[^\W_]+\n)?              # Optionally match a language specifier followed by a newline. +    (?P<code>.+?)                     # Match the actual code within the block. +    \1                                # Match the same 3 ticks used at the start of the block. +    """, +    re.DOTALL | re.VERBOSE +) + +_RE_LANGUAGE = re.compile( +    fr""" +    ^(?P<spaces>\s+)?                    # Optionally match leading spaces from the beginning. +    (?P<lang>{'|'.join(PY_LANG_CODES)})  # Match a Python language. +    (?P<newline>\n)?                     # Optionally match a newline following the language. +    """, +    re.IGNORECASE | re.VERBOSE +) + + +class CodeBlock(NamedTuple): +    """Represents a Markdown code block.""" + +    content: str +    language: str +    tick: str + + +class BadLanguage(NamedTuple): +    """Parsed information about a poorly formatted language specifier.""" + +    language: str +    has_leading_spaces: bool +    has_terminal_newline: bool + + +def find_code_blocks(message: str) -> Optional[Sequence[CodeBlock]]: +    """ +    Find and return all Markdown code blocks in the `message`. + +    Code blocks with 3 or fewer lines are excluded. + +    If the `message` contains at least one code block with valid ticks and a specified language, +    return None. This is based on the assumption that if the user managed to get one code block +    right, they already know how to fix the rest themselves. +    """ +    log.trace("Finding all code blocks in a message.") + +    code_blocks = [] +    for match in _RE_CODE_BLOCK.finditer(message): +        # Used to ensure non-matched groups have an empty string as the default value. +        groups = match.groupdict("") +        language = groups["lang"].strip()  # Strip the newline cause it's included in the group. + +        if groups["tick"] == BACKTICK and language: +            log.trace("Message has a valid code block with a language; returning None.") +            return None +        elif has_lines(groups["code"], constants.CodeBlock.minimum_lines): +            code_block = CodeBlock(groups["code"], language, groups["tick"]) +            code_blocks.append(code_block) +        else: +            log.trace("Skipped a code block shorter than 4 lines.") + +    return code_blocks + + +def _is_python_code(content: str) -> bool: +    """Return True if `content` is valid Python consisting of more than just expressions.""" +    log.trace("Checking if content is Python code.") +    try: +        # Attempt to parse the message into an AST node. +        # Invalid Python code will raise a SyntaxError. +        tree = ast.parse(content) +    except SyntaxError: +        log.trace("Code is not valid Python.") +        return False + +    # Multiple lines of single words could be interpreted as expressions. +    # This check is to avoid all nodes being parsed as expressions. +    # (e.g. words over multiple lines) +    if not all(isinstance(node, ast.Expr) for node in tree.body): +        log.trace("Code is valid python.") +        return True +    else: +        log.trace("Code consists only of expressions.") +        return False + + +def _is_repl_code(content: str, threshold: int = 3) -> bool: +    """Return True if `content` has at least `threshold` number of (I)Python REPL-like lines.""" +    log.trace(f"Checking if content is (I)Python REPL code using a threshold of {threshold}.") + +    repl_lines = 0 +    patterns = (_RE_PYTHON_REPL, _RE_IPYTHON_REPL) + +    for line in content.splitlines(): +        # Check the line against all patterns. +        for pattern in patterns: +            if pattern.match(line): +                repl_lines += 1 + +                # Once a pattern is matched, only use that pattern for the remaining lines. +                patterns = (pattern,) +                break + +        if repl_lines == threshold: +            log.trace("Content is (I)Python REPL code.") +            return True + +    log.trace("Content is not (I)Python REPL code.") +    return False + + +def is_python_code(content: str) -> bool: +    """Return True if `content` is valid Python code or (I)Python REPL output.""" +    dedented = textwrap.dedent(content) + +    # Parse AST twice in case _fix_indentation ends up breaking code due to its inaccuracies. +    return ( +        _is_python_code(dedented) +        or _is_repl_code(dedented) +        or _is_python_code(_fix_indentation(content)) +    ) + + +def parse_bad_language(content: str) -> Optional[BadLanguage]: +    """ +    Return information about a poorly formatted Python language in code block `content`. + +    If the language is not Python, return None. +    """ +    log.trace("Parsing bad language.") + +    match = _RE_LANGUAGE.match(content) +    if not match: +        return None + +    return BadLanguage( +        language=match["lang"], +        has_leading_spaces=match["spaces"] is not None, +        has_terminal_newline=match["newline"] is not None, +    ) + + +def _get_leading_spaces(content: str) -> int: +    """Return the number of spaces at the start of the first line in `content`.""" +    leading_spaces = 0 +    for char in content: +        if char == " ": +            leading_spaces += 1 +        else: +            return leading_spaces + + +def _fix_indentation(content: str) -> str: +    """ +    Attempt to fix badly indented code in `content`. + +    In most cases, this works like textwrap.dedent. However, if the first line ends with a colon, +    all subsequent lines are re-indented to only be one level deep relative to the first line. +    The intent is to fix cases where the leading spaces of the first line of code were accidentally +    not copied, which makes the first line appear not indented. + +    This is fairly naïve and inaccurate. Therefore, it may break some code that was otherwise valid. +    It's meant to catch really common cases, so that's acceptable. Its flaws are: + +    - It assumes that if the first line ends with a colon, it is the start of an indented block +    - It uses 4 spaces as the indentation, regardless of what the rest of the code uses +    """ +    lines = content.splitlines(keepends=True) + +    # Dedent the first line +    first_indent = _get_leading_spaces(content) +    first_line = lines[0][first_indent:] + +    # Can't assume there'll be multiple lines cause line counts of edited messages aren't checked. +    if len(lines) == 1: +        return first_line + +    second_indent = _get_leading_spaces(lines[1]) + +    # If the first line ends with a colon, all successive lines need to be indented one +    # additional level (assumes an indent width of 4). +    if first_line.rstrip().endswith(":"): +        second_indent -= 4 + +    # All lines must be dedented at least by the same amount as the first line. +    first_indent = max(first_indent, second_indent) + +    # Dedent the rest of the lines and join them together with the first line. +    content = first_line + "".join(line[first_indent:] for line in lines[1:]) + +    return content diff --git a/bot/exts/info/doc.py b/bot/exts/info/doc.py index 30c793c75..c16a99225 100644 --- a/bot/exts/info/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 @@ -346,7 +345,7 @@ class Doc(commands.Cog):      @commands.group(name='docs', aliases=('doc', 'd'), invoke_without_command=True)      async def docs_group(self, ctx: commands.Context, symbol: commands.clean_content = None) -> None:          """Lookup documentation for Python symbols.""" -        await ctx.invoke(self.get_command, symbol) +        await self.get_command(ctx, symbol)      @docs_group.command(name='get', aliases=('g',))      async def get_command(self, ctx: commands.Context, symbol: commands.clean_content = None) -> None: @@ -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/exts/info/help.py b/bot/exts/info/help.py index 99d503f5c..599c5d5c0 100644 --- a/bot/exts/info/help.py +++ b/bot/exts/info/help.py @@ -229,7 +229,7 @@ class CustomHelpCommand(HelpCommand):      async def send_cog_help(self, cog: Cog) -> None:          """Send help for a cog.""" -        # sort commands by name, and remove any the user cant run or are hidden. +        # sort commands by name, and remove any the user can't run or are hidden.          commands_ = await self.filter_commands(cog.get_commands(), sort=True)          embed = Embed() diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 5476373c2..50057f1ee 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -2,30 +2,23 @@ import colorsys  import logging  import pprint  import textwrap -from collections import Counter, defaultdict +from collections import defaultdict  from typing import Any, DefaultDict, Dict, Mapping, Optional, Tuple, Union -from discord import ChannelType, Colour, CustomActivity, Embed, Guild, Member, Message, Role, Status, utils +from discord import ChannelType, Colour, Embed, Guild, Member, Message, Role, utils  from discord.abc import GuildChannel -from discord.ext.commands import BucketType, Cog, Context, Paginator, command, group -from discord.utils import escape_markdown +from discord.ext.commands import BucketType, Cog, Context, Paginator, command, group, has_any_role  from bot import constants  from bot.bot import Bot -from bot.decorators import in_whitelist, with_role +from bot.decorators import in_whitelist  from bot.pagination import LinePaginator -from bot.utils.checks import InWhitelistCheckFailure, cooldown_with_role_bypass, with_role_check +from bot.utils.channel import is_mod_channel +from bot.utils.checks import cooldown_with_role_bypass, has_no_roles_check, in_whitelist_check  from bot.utils.time import time_since  log = logging.getLogger(__name__) -STATUS_EMOTES = { -    Status.online: constants.Emojis.status_online, -    Status.idle: constants.Emojis.status_idle, -    Status.dnd: constants.Emojis.status_dnd, -    Status.offline: constants.Emojis.status_offline, -} -  class Information(Cog):      """A cog with commands for generating embeds with server info, such as server stats and user info.""" @@ -69,7 +62,7 @@ class Information(Cog):                  constants.Roles.owners, constants.Roles.contributors,              )          ) -        return {role.name: len(role.members) for role in roles} +        return {role.name.title(): len(role.members) for role in roles}      def get_extended_server_info(self, guild: Guild) -> str:          """Return additional server info only visible in moderation channels.""" @@ -92,7 +85,7 @@ class Information(Cog):              {python_general.mention} cooldown: {python_general.slowmode_delay}s          """) -    @with_role(*constants.MODERATION_ROLES) +    @has_any_role(*constants.STAFF_ROLES)      @command(name="roles")      async def roles_info(self, ctx: Context) -> None:          """Returns a list of all roles and their corresponding IDs.""" @@ -112,7 +105,7 @@ class Information(Cog):          await LinePaginator.paginate(role_list, ctx, embed, empty=False) -    @with_role(*constants.MODERATION_ROLES) +    @has_any_role(*constants.STAFF_ROLES)      @command(name="role")      async def role_info(self, ctx: Context, *roles: Union[Role, str]) -> None:          """ @@ -176,10 +169,20 @@ class Information(Cog):          else:              features = "" +        # Member status +        py_invite = await self.bot.fetch_invite(constants.Guild.invite) +        online_presences = py_invite.approximate_presence_count +        offline_presences = py_invite.approximate_member_count - online_presences +        member_status = ( +            f"{constants.Emojis.status_online} {online_presences} " +            f"{constants.Emojis.status_offline} {offline_presences}" +        ) +          embed.description = textwrap.dedent(f"""              Created: {created}              Voice region: {region}{features}              Roles: {num_roles} +            Member status: {member_status}          """)          embed.set_thumbnail(url=ctx.guild.icon_url) @@ -187,7 +190,7 @@ class Information(Cog):          total_members = ctx.guild.member_count          member_counts = self.get_member_counts(ctx.guild)          member_info = "\n".join( -            f"{role.title()}: {count}" for role, count in member_counts.items() +            f"{role}: {count}" for role, count in member_counts.items()          )          embed.add_field(name=f"Members: {total_members}", value=member_info) @@ -199,13 +202,6 @@ class Information(Cog):          )          embed.add_field(name=f"Channels: {total_channels}", value=channel_info) -        # Member status -        status_count = Counter(member.status for member in ctx.guild.members) -        member_status = " ".join( -            f"{emoji} {status_count[status]:,}" for status, emoji in STATUS_EMOTES.items() -        ) -        embed.add_field(name="Member Status:", value=member_status, inline=False) -          # Additional info if ran in moderation channels          if ctx.channel.id in constants.MODERATION_CHANNELS:              embed.add_field( @@ -221,42 +217,19 @@ class Information(Cog):              user = ctx.author          # Do a role check if this is being executed on someone other than the caller -        elif user != ctx.author and not with_role_check(ctx, *constants.MODERATION_ROLES): +        elif user != ctx.author and await has_no_roles_check(ctx, *constants.MODERATION_ROLES):              await ctx.send("You may not use this command on users other than yourself.")              return -        # Non-staff may only do this in #bot-commands -        if not with_role_check(ctx, *constants.STAFF_ROLES): -            if not ctx.channel.id == constants.Channels.bot_commands: -                raise InWhitelistCheckFailure(constants.Channels.bot_commands) - -        embed = await self.create_user_embed(ctx, user) - -        await ctx.send(embed=embed) +        # Will redirect to #bot-commands if it fails. +        if in_whitelist_check(ctx, roles=constants.STAFF_ROLES): +            embed = await self.create_user_embed(ctx, user) +            await ctx.send(embed=embed)      async def create_user_embed(self, ctx: Context, user: Member) -> Embed:          """Creates an embed containing information on the `user`."""          created = time_since(user.created_at, max_units=3) -        # Custom status -        custom_status = '' -        for activity in user.activities: -            if isinstance(activity, CustomActivity): -                state = "" - -                if activity.name: -                    state = escape_markdown(activity.name) - -                emoji = "" -                if activity.emoji: -                    # If an emoji is unicode use the emoji, else write the emote like :abc: -                    if not activity.emoji.id: -                        emoji += activity.emoji.name + " " -                    else: -                        emoji += f"`:{activity.emoji.name}:` " - -                custom_status = f'Status: {emoji}{state}\n' -          name = str(user)          if user.nick:              name = f"{user.nick} ({name})" @@ -270,10 +243,6 @@ class Information(Cog):          joined = time_since(user.joined_at, max_units=3)          roles = ", ".join(role.mention for role in user.roles[1:]) -        desktop_status = STATUS_EMOTES.get(user.desktop_status, constants.Emojis.status_online) -        web_status = STATUS_EMOTES.get(user.web_status, constants.Emojis.status_online) -        mobile_status = STATUS_EMOTES.get(user.mobile_status, constants.Emojis.status_online) -          fields = [              (                  "User information", @@ -281,7 +250,6 @@ class Information(Cog):                      Created: {created}                      Profile: {user.mention}                      ID: {user.id} -                    {custom_status}                  """).strip()              ),              ( @@ -291,18 +259,10 @@ class Information(Cog):                      Roles: {roles or None}                  """).strip()              ), -            ( -                "Status", -                textwrap.dedent(f""" -                    {desktop_status} Desktop -                    {web_status} Web -                    {mobile_status} Mobile -                """).strip() -            )          ]          # Show more verbose output in moderation channels for infractions and nominations -        if ctx.channel.id in constants.MODERATION_CHANNELS: +        if is_mod_channel(ctx.channel):              fields.append(await self.expanded_user_infraction_counts(user))              fields.append(await self.user_nomination_counts(user))          else: diff --git a/bot/exts/info/reddit.py b/bot/exts/info/reddit.py index 5d9e2c20b..bad4c504d 100644 --- a/bot/exts/info/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 discord.utils import escape_markdown, sleep_until  from bot.bot import Bot  from bot.constants import Channels, ERROR_REPLIES, Emojis, Reddit as RedditConfig, STAFF_ROLES, Webhooks  from bot.converters import Subreddit -from bot.decorators import with_role  from bot.pagination import LinePaginator  from bot.utils.messages import sub_clyde @@ -141,7 +140,10 @@ class Reddit(Cog):                  # Got appropriate response - process and return.                  content = await response.json()                  posts = content["data"]["children"] -                return posts[:amount] + +                filtered_posts = [post for post in posts if not post["data"]["over_18"]] + +                return filtered_posts[:amount]              await asyncio.sleep(3) @@ -164,12 +166,11 @@ class Reddit(Cog):              amount=amount,              params={"t": time}          ) -          if not posts:              embed.title = random.choice(ERROR_REPLIES)              embed.colour = Colour.red()              embed.description = ( -                "Sorry! We couldn't find any posts from that subreddit. " +                "Sorry! We couldn't find any SFW posts from that subreddit. "                  "If this problem persists, please let us know."              ) @@ -204,13 +205,13 @@ class Reddit(Cog):      @loop()      async def auto_poster_loop(self) -> None:          """Post the top 5 posts daily, and the top 5 posts weekly.""" -        # once we upgrade to d.py 1.3 this can be removed and the loop can use the `time=datetime.time.min` parameter +        # once d.py get support for `time` parameter in loop decorator, +        # this can be removed and the loop can use the `time=datetime.time.min` parameter          now = datetime.utcnow()          tomorrow = now + timedelta(days=1)          midnight_tomorrow = tomorrow.replace(hour=0, minute=0, second=0) -        seconds_until = (midnight_tomorrow - now).total_seconds() -        await asyncio.sleep(seconds_until) +        await sleep_until(midnight_tomorrow)          await self.bot.wait_until_guild_available()          if not self.webhook: @@ -282,7 +283,7 @@ class Reddit(Cog):          await ctx.send(content=f"Here are this week's top {subreddit} posts!", embed=embed) -    @with_role(*STAFF_ROLES) +    @has_any_role(*STAFF_ROLES)      @reddit_group.command(name="subreddits", aliases=("subs",))      async def subreddits_command(self, ctx: Context) -> None:          """Send a paginated embed of all the subreddits we're relaying.""" diff --git a/bot/exts/info/site.py b/bot/exts/info/site.py index 2d3a3d9f3..fb5b99086 100644 --- a/bot/exts/info/site.py +++ b/bot/exts/info/site.py @@ -1,7 +1,7 @@  import logging  from discord import Colour, Embed -from discord.ext.commands import Cog, Context, group +from discord.ext.commands import Cog, Context, Greedy, group  from bot.bot import Bot  from bot.constants import URLs @@ -105,10 +105,9 @@ class Site(Cog):          await ctx.send(embed=embed)      @site_group.command(name="rules", aliases=("r", "rule"), root_aliases=("rules", "rule")) -    async def site_rules(self, ctx: Context, *rules: int) -> None: +    async def site_rules(self, ctx: Context, rules: Greedy[int]) -> None:          """Provides a link to all rules or, if specified, displays specific rule(s).""" -        rules_embed = Embed(title='Rules', color=Colour.blurple()) -        rules_embed.url = f"{PAGES_URL}/rules" +        rules_embed = Embed(title='Rules', color=Colour.blurple(), url=f'{PAGES_URL}/rules')          if not rules:              # Rules were not submitted. Return the default description. @@ -122,15 +121,13 @@ class Site(Cog):              return          full_rules = await self.bot.api_client.get('rules', params={'link_format': 'md'}) -        invalid_indices = tuple( -            pick -            for pick in rules -            if pick < 1 or pick > len(full_rules) -        ) -        if invalid_indices: -            indices = ', '.join(map(str, invalid_indices)) -            await ctx.send(f":x: Invalid rule indices: {indices}") +        # Remove duplicates and sort the rule indices +        rules = sorted(set(rules)) +        invalid = ', '.join(str(index) for index in rules if index < 1 or index > len(full_rules)) + +        if invalid: +            await ctx.send(f":x: Invalid rule indices: {invalid}")              return          for rule in rules: diff --git a/bot/exts/info/source.py b/bot/exts/info/source.py index 205e0ba81..7b41352d4 100644 --- a/bot/exts/info/source.py +++ b/bot/exts/info/source.py @@ -2,7 +2,7 @@ import inspect  from pathlib import Path  from typing import Optional, Tuple, Union -from discord import Embed +from discord import Embed, utils  from discord.ext import commands  from bot.bot import Bot @@ -35,8 +35,10 @@ class SourceConverter(commands.Converter):          elif argument.lower() in tags_cog._cache:              return argument.lower() +        escaped_arg = utils.escape_markdown(argument) +          raise commands.BadArgument( -            f"Unable to convert `{argument}` to valid command{', tag,' if show_tag else ''} or Cog." +            f"Unable to convert '{escaped_arg}' to valid command{', tag,' if show_tag else ''} or Cog."          ) @@ -66,14 +68,8 @@ class BotSource(commands.Cog):          Raise BadArgument if `source_item` is a dynamically-created object (e.g. via internal eval).          """          if isinstance(source_item, commands.Command): -            if source_item.cog_name == "Alias": -                cmd_name = source_item.callback.__name__.replace("_alias", "") -                cmd = self.bot.get_command(cmd_name.replace("_", " ")) -                src = cmd.callback.__code__ -                filename = src.co_filename -            else: -                src = source_item.callback.__code__ -                filename = src.co_filename +            src = source_item.callback.__code__ +            filename = src.co_filename          elif isinstance(source_item, str):              tags_cog = self.bot.get_cog("Tags")              filename = tags_cog._cache[source_item]["location"] @@ -113,13 +109,7 @@ class BotSource(commands.Cog):              title = "Help Command"              description = source_object.__doc__.splitlines()[1]          elif isinstance(source_object, commands.Command): -            if source_object.cog_name == "Alias": -                cmd_name = source_object.callback.__name__.replace("_alias", "") -                cmd = self.bot.get_command(cmd_name.replace("_", " ")) -                description = cmd.short_doc -            else: -                description = source_object.short_doc - +            description = source_object.short_doc              title = f"Command: {source_object.qualified_name}"          elif isinstance(source_object, str):              title = f"Tag: {source_object}" diff --git a/bot/exts/info/stats.py b/bot/exts/info/stats.py index d42f55466..4d8bb645e 100644 --- a/bot/exts/info/stats.py +++ b/bot/exts/info/stats.py @@ -1,13 +1,12 @@  import string -from datetime import datetime -from discord import Member, Message, Status +from discord import Member, Message  from discord.ext.commands import Cog, Context  from discord.ext.tasks import loop  from bot.bot import Bot -from bot.constants import Categories, Channels, Guild, Stats as StatConf - +from bot.constants import Categories, Channels, Guild +from bot.utils.channel import is_in_category  CHANNEL_NAME_OVERRIDES = {      Channels.off_topic_0: "off_topic_0", @@ -36,8 +35,7 @@ class Stats(Cog):          if message.guild.id != Guild.id:              return -        cat = getattr(message.channel, "category", None) -        if cat is not None and cat.id == Categories.modmail: +        if is_in_category(message.channel, Categories.modmail):              if message.channel.id != Channels.incidents:                  # Do not report modmail channels to stats, there are too many                  # of them for interesting statistics to be drawn out of this. @@ -79,38 +77,6 @@ class Stats(Cog):          self.bot.stats.gauge("guild.total_members", len(member.guild.members)) -    @Cog.listener() -    async def on_member_update(self, _before: Member, after: Member) -> None: -        """Update presence estimates on member update.""" -        if after.guild.id != Guild.id: -            return - -        if self.last_presence_update: -            if (datetime.now() - self.last_presence_update).seconds < StatConf.presence_update_timeout: -                return - -        self.last_presence_update = datetime.now() - -        online = 0 -        idle = 0 -        dnd = 0 -        offline = 0 - -        for member in after.guild.members: -            if member.status is Status.online: -                online += 1 -            elif member.status is Status.dnd: -                dnd += 1 -            elif member.status is Status.idle: -                idle += 1 -            elif member.status is Status.offline: -                offline += 1 - -        self.bot.stats.gauge("guild.status.online", online) -        self.bot.stats.gauge("guild.status.idle", idle) -        self.bot.stats.gauge("guild.status.do_not_disturb", dnd) -        self.bot.stats.gauge("guild.status.offline", offline) -      @loop(hours=1)      async def update_guild_boost(self) -> None:          """Post the server boost level and tier every hour.""" diff --git a/bot/exts/info/tags.py b/bot/exts/info/tags.py index d01647312..ae95ac1ef 100644 --- a/bot/exts/info/tags.py +++ b/bot/exts/info/tags.py @@ -160,7 +160,7 @@ class Tags(Cog):      @group(name='tags', aliases=('tag', 't'), invoke_without_command=True)      async def tags_group(self, ctx: Context, *, tag_name: TagNameConverter = None) -> None:          """Show all known tags, a single tag, or run a subcommand.""" -        await ctx.invoke(self.get_command, tag_name=tag_name) +        await self.get_command(ctx, tag_name=tag_name)      @tags_group.group(name='search', invoke_without_command=True)      async def search_tag_content(self, ctx: Context, *, keywords: str) -> None: diff --git a/bot/exts/moderation/defcon.py b/bot/exts/moderation/defcon.py index 6e4008777..caa6fb917 100644 --- a/bot/exts/moderation/defcon.py +++ b/bot/exts/moderation/defcon.py @@ -6,12 +6,12 @@ from datetime import datetime, timedelta  from enum import Enum  from discord import Colour, Embed, Member -from discord.ext.commands import Cog, Context, group +from discord.ext.commands import Cog, Context, group, has_any_role  from bot.bot import Bot  from bot.constants import Channels, Colours, Emojis, Event, Icons, MODERATION_ROLES, Roles -from bot.decorators import with_role  from bot.exts.moderation.modlog import ModLog +from bot.utils.messages import format_user  log = logging.getLogger(__name__) @@ -107,7 +107,7 @@ class Defcon(Cog):                  self.bot.stats.incr("defcon.leaves")                  message = ( -                    f"{member} (`{member.id}`) was denied entry because their account is too new." +                    f"{format_user(member)} was denied entry because their account is too new."                  )                  if not message_sent: @@ -119,7 +119,7 @@ class Defcon(Cog):                  )      @group(name='defcon', aliases=('dc',), invoke_without_command=True) -    @with_role(*MODERATION_ROLES) +    @has_any_role(*MODERATION_ROLES)      async def defcon_group(self, ctx: Context) -> None:          """Check the DEFCON status or run a subcommand."""          await ctx.send_help(ctx.command) @@ -163,7 +163,7 @@ class Defcon(Cog):              self.bot.stats.gauge("defcon.threshold", days)      @defcon_group.command(name='enable', aliases=('on', 'e'), root_aliases=("defon",)) -    @with_role(*MODERATION_ROLES) +    @has_any_role(*MODERATION_ROLES)      async def enable_command(self, ctx: Context) -> None:          """          Enable DEFCON mode. Useful in a pinch, but be sure you know what you're doing! @@ -176,7 +176,7 @@ class Defcon(Cog):          await self.update_channel_topic()      @defcon_group.command(name='disable', aliases=('off', 'd'), root_aliases=("defoff",)) -    @with_role(*MODERATION_ROLES) +    @has_any_role(*MODERATION_ROLES)      async def disable_command(self, ctx: Context) -> None:          """Disable DEFCON mode. Useful in a pinch, but be sure you know what you're doing!"""          self.enabled = False @@ -184,7 +184,7 @@ class Defcon(Cog):          await self.update_channel_topic()      @defcon_group.command(name='status', aliases=('s',)) -    @with_role(*MODERATION_ROLES) +    @has_any_role(*MODERATION_ROLES)      async def status_command(self, ctx: Context) -> None:          """Check the current status of DEFCON mode."""          embed = Embed( @@ -196,7 +196,7 @@ class Defcon(Cog):          await ctx.send(embed=embed)      @defcon_group.command(name='days') -    @with_role(*MODERATION_ROLES) +    @has_any_role(*MODERATION_ROLES)      async def days_command(self, ctx: Context, days: int) -> None:          """Set how old an account must be to join the server, in days, with DEFCON mode enabled."""          self.days = timedelta(days=days) diff --git a/bot/exts/moderation/dm_relay.py b/bot/exts/moderation/dm_relay.py index 0d8f340b4..4d5142b55 100644 --- a/bot/exts/moderation/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 @@ -90,7 +90,11 @@ class DMRelay(Cog):          # Handle any attachments          if message.attachments:              try: -                await send_attachments(message, self.webhook) +                await send_attachments( +                    message, +                    self.webhook, +                    username=f"{message.author.display_name} ({message.author.id})" +                )              except (discord.errors.Forbidden, discord.errors.NotFound):                  e = discord.Embed(                      description=":x: **This message contained an attachment, but it could not be retrieved**", @@ -105,10 +109,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/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index e49913552..0e479d33f 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -237,7 +237,7 @@ class Incidents(Cog):          not all information was relayed, return False. This signals that the original          message is not safe to be deleted, as we will lose some information.          """ -        log.debug(f"Archiving incident: {incident.id} (outcome: {outcome}, actioned by: {actioned_by})") +        log.info(f"Archiving incident: {incident.id} (outcome: {outcome}, actioned by: {actioned_by})")          embed, attachment_file = await make_embed(incident, outcome, actioned_by)          try: @@ -319,7 +319,7 @@ class Incidents(Cog):          try:              await confirmation_task          except asyncio.TimeoutError: -            log.warning(f"Did not receive incident deletion confirmation within {timeout} seconds!") +            log.info(f"Did not receive incident deletion confirmation within {timeout} seconds!")          else:              log.trace("Deletion was confirmed") diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index cf48ef2ac..bebade0ae 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -12,12 +12,12 @@ from discord.ext.commands import Context  from bot import constants  from bot.api import ResponseCodeError  from bot.bot import Bot -from bot.constants import Colours, STAFF_CHANNELS +from bot.constants import Colours  from bot.exts.moderation.infraction import _utils  from bot.exts.moderation.infraction._utils import UserSnowflake  from bot.exts.moderation.modlog import ModLog -from bot.utils import time -from bot.utils.scheduling import Scheduler +from bot.utils import messages, scheduling, time +from bot.utils.channel import is_mod_channel  log = logging.getLogger(__name__) @@ -27,7 +27,7 @@ class InfractionScheduler:      def __init__(self, bot: Bot, supported_infractions: t.Container[str]):          self.bot = bot -        self.scheduler = Scheduler(self.__class__.__name__) +        self.scheduler = scheduling.Scheduler(self.__class__.__name__)          self.bot.loop.create_task(self.reschedule_infractions(supported_infractions)) @@ -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, " ".join(infr_type.split("_")).title(), expiry, reason, icon):                      dm_result = ":incoming_envelope: "                      dm_log_text = "\nDM: Sent" @@ -137,11 +137,7 @@ class InfractionScheduler:              )              if reason:                  end_msg = f" (reason: {textwrap.shorten(reason, width=1500, placeholder='...')})" -        elif ctx.channel.id not in STAFF_CHANNELS: -            log.trace( -                f"Infraction #{id_} context is not in a staff channel; omitting infraction count." -            ) -        else: +        elif is_mod_channel(ctx.channel):              log.trace(f"Fetching total infraction count for {user}.")              infractions = await self.bot.api_client.get( @@ -149,7 +145,7 @@ class InfractionScheduler:                  params={"user__id": str(user.id)}              )              total = len(infractions) -            end_msg = f" ({total} infraction{ngettext('', 's', total)} total)" +            end_msg = f" (#{id_} ; {total} infraction{ngettext('', 's', total)} total)"          # Execute the necessary actions to apply the infraction on Discord.          if action_coro: @@ -167,7 +163,7 @@ class InfractionScheduler:                  log_content = ctx.author.mention                  log_title = "failed to apply" -                log_msg = f"Failed to apply {infr_type} infraction #{id_} to {user}" +                log_msg = f"Failed to apply {' '.join(infr_type.split('_'))} infraction #{id_} to {user}"                  if isinstance(e, discord.Forbidden):                      log.warning(f"{log_msg}: bot lacks permissions.")                  else: @@ -184,7 +180,7 @@ class InfractionScheduler:                  log.error(f"Deletion of {infr_type} infraction #{id_} failed with error code {e.status}.")              infr_message = ""          else: -            infr_message = f" **{infr_type}** to {user.mention}{expiry_msg}{end_msg}" +            infr_message = f" **{' '.join(infr_type.split('_'))}** to {user.mention}{expiry_msg}{end_msg}"          # Send a confirmation message to the invoking context.          log.trace(f"Sending infraction #{id_} confirmation message.") @@ -196,11 +192,11 @@ class InfractionScheduler:          await self.mod_log.send_log_message(              icon_url=icon,              colour=Colours.soft_red, -            title=f"Infraction {log_title}: {infr_type}", +            title=f"Infraction {log_title}: {' '.join(infr_type.split('_'))}",              thumbnail=user.avatar_url_as(static_format="png"),              text=textwrap.dedent(f""" -                Member: {user.mention} (`{user.id}`) -                Actor: {ctx.author}{dm_log_text}{expiry_log_text} +                Member: {messages.format_user(user)} +                Actor: {ctx.author.mention}{dm_log_text}{expiry_log_text}                  Reason: {reason}              """),              content=log_content, @@ -243,48 +239,12 @@ class InfractionScheduler:          # Deactivate the infraction and cancel its scheduled expiration task.          log_text = await self.deactivate_infraction(response[0], send_log=False) -        log_text["Member"] = f"{user.mention}(`{user.id}`)" -        log_text["Actor"] = str(ctx.author) +        log_text["Member"] = messages.format_user(user) +        log_text["Actor"] = ctx.author.mention          log_content = None          id_ = response[0]['id']          footer = f"ID: {id_}" -        # If multiple active infractions were found, mark them as inactive in the database -        # and cancel their expiration tasks. -        if len(response) > 1: -            log.info( -                f"Found more than one active {infr_type} infraction for user {user.id}; " -                "deactivating the extra active infractions too." -            ) - -            footer = f"Infraction IDs: {', '.join(str(infr['id']) for infr in response)}" - -            log_note = f"Found multiple **active** {infr_type} infractions in the database." -            if "Note" in log_text: -                log_text["Note"] = f" {log_note}" -            else: -                log_text["Note"] = log_note - -            # deactivate_infraction() is not called again because: -            #     1. Discord cannot store multiple active bans or assign multiples of the same role -            #     2. It would send a pardon DM for each active infraction, which is redundant -            for infraction in response[1:]: -                id_ = infraction['id'] -                try: -                    # Mark infraction as inactive in the database. -                    await self.bot.api_client.patch( -                        f"bot/infractions/{id_}", -                        json={"active": False} -                    ) -                except ResponseCodeError: -                    log.exception(f"Failed to deactivate infraction #{id_} ({infr_type})") -                    # This is simpler and cleaner than trying to concatenate all the errors. -                    log_text["Failure"] = "See bot's logs for details." - -                # Cancel pending expiration task. -                if infraction["expires_at"] is not None: -                    self.scheduler.cancel(infraction["id"]) -          # Accordingly display whether the user was successfully notified via DM.          dm_emoji = ""          if log_text.get("DM") == "Sent": @@ -309,7 +269,7 @@ class InfractionScheduler:          if send_msg:              log.trace(f"Sending infraction #{id_} pardon confirmation message.")              await ctx.send( -                f"{dm_emoji}{confirm_msg} infraction **{infr_type}** for {user.mention}. " +                f"{dm_emoji}{confirm_msg} infraction **{' '.join(infr_type.split('_'))}** for {user.mention}. "                  f"{log_text.get('Failure', '')}"              ) @@ -320,7 +280,7 @@ class InfractionScheduler:          await self.mod_log.send_log_message(              icon_url=_utils.INFRACTION_ICONS[infr_type][1],              colour=Colours.soft_green, -            title=f"Infraction {log_title}: {infr_type}", +            title=f"Infraction {log_title}: {' '.join(infr_type.split('_'))}",              thumbnail=user.avatar_url_as(static_format="png"),              text="\n".join(f"{k}: {v}" for k, v in log_text.items()),              footer=footer, @@ -358,7 +318,7 @@ class InfractionScheduler:          log_content = None          log_text = {              "Member": f"<@{user_id}>", -            "Actor": str(self.bot.get_user(actor) or actor), +            "Actor": f"<@{actor}>",              "Reason": infraction["reason"],              "Created": created,          } diff --git a/bot/exts/moderation/infraction/_utils.py b/bot/exts/moderation/infraction/_utils.py index f21272102..d0dc3f0a1 100644 --- a/bot/exts/moderation/infraction/_utils.py +++ b/bot/exts/moderation/infraction/_utils.py @@ -1,5 +1,4 @@  import logging -import textwrap  import typing as t  from datetime import datetime @@ -19,15 +18,28 @@ INFRACTION_ICONS = {      "note": (Icons.user_warn, None),      "superstar": (Icons.superstarify, Icons.unsuperstarify),      "warning": (Icons.user_warn, None), +    "voice_ban": (Icons.voice_state_red, Icons.voice_state_green),  }  RULES_URL = "https://pythondiscord.com/pages/rules" -APPEALABLE_INFRACTIONS = ("ban", "mute") +APPEALABLE_INFRACTIONS = ("ban", "mute", "voice_ban")  # Type aliases  UserObject = t.Union[discord.Member, discord.User]  UserSnowflake = t.Union[UserObject, discord.Object]  Infraction = t.Dict[str, t.Union[str, int, bool]] +APPEAL_EMAIL = "[email protected]" + +INFRACTION_TITLE = f"Please review our rules over at {RULES_URL}" +INFRACTION_APPEAL_FOOTER = f"To appeal this infraction, send an e-mail to {APPEAL_EMAIL}" +INFRACTION_AUTHOR_NAME = "Infraction information" + +INFRACTION_DESCRIPTION_TEMPLATE = ( +    "**Type:** {type}\n" +    "**Expires:** {expires}\n" +    "**Reason:** {reason}\n" +) +  async def post_user(ctx: Context, user: UserSnowflake) -> t.Optional[dict]:      """ @@ -142,25 +154,27 @@ async def notify_infraction(      """DM a user about their new infraction and return True if the DM is successful."""      log.trace(f"Sending {user} a DM about their {infr_type} infraction.") -    text = textwrap.dedent(f""" -        **Type:** {infr_type.capitalize()} -        **Expires:** {expires_at or "N/A"} -        **Reason:** {reason or "No reason provided."} -    """) +    text = INFRACTION_DESCRIPTION_TEMPLATE.format( +        type=infr_type.title(), +        expires=expires_at or "N/A", +        reason=reason or "No reason provided." +    ) + +    # For case when other fields than reason is too long and this reach limit, then force-shorten string +    if len(text) > 2048: +        text = f"{text[:2045]}..."      embed = discord.Embed( -        description=textwrap.shorten(text, width=2048, placeholder="..."), +        description=text,          colour=Colours.soft_red      ) -    embed.set_author(name="Infraction information", icon_url=icon_url, url=RULES_URL) -    embed.title = f"Please review our rules over at {RULES_URL}" +    embed.set_author(name=INFRACTION_AUTHOR_NAME, icon_url=icon_url, url=RULES_URL) +    embed.title = INFRACTION_TITLE      embed.url = RULES_URL      if infr_type in APPEALABLE_INFRACTIONS: -        embed.set_footer( -            text="To appeal this infraction, send an e-mail to [email protected]" -        ) +        embed.set_footer(text=INFRACTION_APPEAL_FOOTER)      return await send_private_embed(user, embed) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index 84ea47371..746d4e154 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -15,7 +15,7 @@ from bot.decorators import respect_role_hierarchy  from bot.exts.moderation.infraction import _utils  from bot.exts.moderation.infraction._scheduler import InfractionScheduler  from bot.exts.moderation.infraction._utils import UserSnowflake -from bot.utils.checks import with_role_check +from bot.utils.messages import format_user  log = logging.getLogger(__name__) @@ -31,6 +31,7 @@ class Infractions(InfractionScheduler, commands.Cog):          self.category = "Moderation"          self._muted_role = discord.Object(constants.Roles.muted) +        self._voice_verified_role = discord.Object(constants.Roles.voice_verified)      @commands.Cog.listener()      async def on_member_join(self, member: Member) -> None: @@ -71,6 +72,28 @@ class Infractions(InfractionScheduler, commands.Cog):          """Permanently ban a user for the given reason and stop watching them with Big Brother."""          await self.apply_ban(ctx, user, reason) +    @command(aliases=('pban',)) +    async def purgeban( +        self, +        ctx: Context, +        user: FetchedMember, +        purge_days: t.Optional[int] = 1, +        *, +        reason: t.Optional[str] = None +    ) -> None: +        """ +        Same as ban but removes all their messages for the given number of days, default being 1. + +        `purge_days` can only be values between 0 and 7. +        Anything outside these bounds are automatically adjusted to their respective limits. +        """ +        await self.apply_ban(ctx, user, reason, max(min(purge_days, 7), 0)) + +    @command(aliases=('vban',)) +    async def voiceban(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str]) -> None: +        """Permanently ban user from using voice channels.""" +        await self.apply_voice_ban(ctx, user, reason) +      # endregion      # region: Temporary infractions @@ -119,6 +142,32 @@ class Infractions(InfractionScheduler, commands.Cog):          """          await self.apply_ban(ctx, user, reason, expires_at=duration) +    @command(aliases=("tempvban", "tvban")) +    async def tempvoiceban( +            self, +            ctx: Context, +            user: FetchedMember, +            duration: Expiry, +            *, +            reason: t.Optional[str] +    ) -> None: +        """ +        Temporarily voice ban a user for the given reason and duration. + +        A unit of time should be appended to the duration. +        Units (∗case-sensitive): +        \u2003`y` - years +        \u2003`m` - months∗ +        \u2003`w` - weeks +        \u2003`d` - days +        \u2003`h` - hours +        \u2003`M` - minutes∗ +        \u2003`s` - seconds + +        Alternatively, an ISO 8601 timestamp can be provided for the duration. +        """ +        await self.apply_voice_ban(ctx, user, reason, expires_at=duration) +      # endregion      # region: Permanent shadow infractions @@ -208,6 +257,11 @@ class Infractions(InfractionScheduler, commands.Cog):          """Prematurely end the active ban infraction for the user."""          await self.pardon_infraction(ctx, "ban", user) +    @command(aliases=("uvban",)) +    async def unvoiceban(self, ctx: Context, user: FetchedMember) -> None: +        """Prematurely end the active voice ban infraction for the user.""" +        await self.pardon_infraction(ctx, "voice_ban", user) +      # endregion      # region: Base apply functions @@ -230,7 +284,7 @@ class Infractions(InfractionScheduler, commands.Cog):          await self.apply_infraction(ctx, infraction, user, action()) -    @respect_role_hierarchy() +    @respect_role_hierarchy(member_arg=2)      async def apply_kick(self, ctx: Context, user: Member, reason: t.Optional[str], **kwargs) -> None:          """Apply a kick infraction with kwargs passed to `post_infraction`."""          infraction = await _utils.post_infraction(ctx, user, "kick", reason, active=False, **kwargs) @@ -245,8 +299,15 @@ class Infractions(InfractionScheduler, commands.Cog):          action = user.kick(reason=reason)          await self.apply_infraction(ctx, infraction, user, action) -    @respect_role_hierarchy() -    async def apply_ban(self, ctx: Context, user: UserSnowflake, reason: t.Optional[str], **kwargs) -> None: +    @respect_role_hierarchy(member_arg=2) +    async def apply_ban( +        self, +        ctx: Context, +        user: UserSnowflake, +        reason: t.Optional[str], +        purge_days: t.Optional[int] = 0, +        **kwargs +    ) -> None:          """          Apply a ban infraction with kwargs passed to `post_infraction`. @@ -278,7 +339,7 @@ class Infractions(InfractionScheduler, commands.Cog):          if reason:              reason = textwrap.shorten(reason, width=512, placeholder="...") -        action = ctx.guild.ban(user, reason=reason, delete_message_days=0) +        action = ctx.guild.ban(user, reason=reason, delete_message_days=purge_days)          await self.apply_infraction(ctx, infraction, user, action)          if infraction.get('expires_at') is not None: @@ -295,6 +356,26 @@ class Infractions(InfractionScheduler, commands.Cog):          bb_reason = "User has been permanently banned from the server. Automatically removed."          await bb_cog.apply_unwatch(ctx, user, bb_reason, send_message=False) +    @respect_role_hierarchy(member_arg=2) +    async def apply_voice_ban(self, ctx: Context, user: UserSnowflake, reason: t.Optional[str], **kwargs) -> None: +        """Apply a voice ban infraction with kwargs passed to `post_infraction`.""" +        if await _utils.get_active_infraction(ctx, user, "voice_ban"): +            return + +        infraction = await _utils.post_infraction(ctx, user, "voice_ban", reason, active=True, **kwargs) +        if infraction is None: +            return + +        self.mod_log.ignore(Event.member_update, user.id) + +        if reason: +            reason = textwrap.shorten(reason, width=512, placeholder="...") + +        await user.move_to(None, reason="Disconnected from voice to apply voiceban.") + +        action = user.remove_roles(self._voice_verified_role, reason=reason) +        await self.apply_infraction(ctx, infraction, user, action) +      # endregion      # region: Base pardon functions @@ -316,7 +397,7 @@ class Infractions(InfractionScheduler, commands.Cog):                  icon_url=_utils.INFRACTION_ICONS["mute"][1]              ) -            log_text["Member"] = f"{user.mention}(`{user.id}`)" +            log_text["Member"] = format_user(user)              log_text["DM"] = "Sent" if notified else "**Failed**"          else:              log.info(f"Failed to unmute user {user_id}: user not found") @@ -339,6 +420,27 @@ class Infractions(InfractionScheduler, commands.Cog):          return log_text +    async def pardon_voice_ban(self, user_id: int, guild: discord.Guild, reason: t.Optional[str]) -> t.Dict[str, str]: +        """Add Voice Verified role back to user, DM them a notification, and return a log dict.""" +        user = guild.get_member(user_id) +        log_text = {} + +        if user: +            # DM user about infraction expiration +            notified = await _utils.notify_pardon( +                user=user, +                title="Voice ban ended", +                content="You have been unbanned and can verify yourself again in the server.", +                icon_url=_utils.INFRACTION_ICONS["voice_ban"][1] +            ) + +            log_text["Member"] = format_user(user) +            log_text["DM"] = "Sent" if notified else "**Failed**" +        else: +            log_text["Info"] = "User was not found in the guild." + +        return log_text +      async def _pardon_action(self, infraction: _utils.Infraction) -> t.Optional[t.Dict[str, str]]:          """          Execute deactivation steps specific to the infraction's type and return a log dict. @@ -353,13 +455,15 @@ class Infractions(InfractionScheduler, commands.Cog):              return await self.pardon_mute(user_id, guild, reason)          elif infraction["type"] == "ban":              return await self.pardon_ban(user_id, guild, reason) +        elif infraction["type"] == "voice_ban": +            return await self.pardon_voice_ban(user_id, guild, reason)      # endregion      # This cannot be static (must have a __func__ attribute). -    def cog_check(self, ctx: Context) -> bool: +    async def cog_check(self, ctx: Context) -> bool:          """Only allow moderators to invoke the commands in this cog.""" -        return with_role_check(ctx, *constants.MODERATION_ROLES) +        return await commands.has_any_role(*constants.MODERATION_ROLES).predicate(ctx)      # This cannot be static (must have a __func__ attribute).      async def cog_command_error(self, ctx: Context, error: Exception) -> None: diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index 5875abd26..394f63da3 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -6,16 +6,16 @@ from datetime import datetime  import discord  from discord.ext import commands  from discord.ext.commands import Context +from discord.utils import escape_markdown  from bot import constants  from bot.bot import Bot -from bot.converters import Expiry, InfractionSearchQuery, allowed_strings, proxy_user -from bot.exts.moderation.infraction import _utils +from bot.converters import Expiry, Snowflake, UserMention, allowed_strings, proxy_user  from bot.exts.moderation.infraction.infractions import Infractions  from bot.exts.moderation.modlog import ModLog  from bot.pagination import LinePaginator -from bot.utils import time -from bot.utils.checks import in_whitelist_check, with_role_check +from bot.utils import messages, time +from bot.utils.channel import is_mod_channel  log = logging.getLogger(__name__) @@ -154,16 +154,12 @@ class ModManagement(commands.Cog):          user = ctx.guild.get_member(user_id)          if user: -            user_text = f"{user.mention} (`{user.id}`)" +            user_text = messages.format_user(user)              thumbnail = user.avatar_url_as(static_format="png")          else: -            user_text = f"`{user_id}`" +            user_text = f"<@{user_id}>"              thumbnail = None -        # The infraction's actor -        actor_id = new_infraction['actor'] -        actor = ctx.guild.get_member(actor_id) or f"`{actor_id}`" -          await self.mod_log.send_log_message(              icon_url=constants.Icons.pencil,              colour=discord.Colour.blurple(), @@ -171,8 +167,8 @@ class ModManagement(commands.Cog):              thumbnail=thumbnail,              text=textwrap.dedent(f"""                  Member: {user_text} -                Actor: {actor} -                Edited by: {ctx.message.author}{log_text} +                Actor: <@{new_infraction['actor']}> +                Edited by: {ctx.message.author.mention}{log_text}              """)          ) @@ -180,20 +176,27 @@ class ModManagement(commands.Cog):      # region: Search infractions      @infraction_group.group(name="search", invoke_without_command=True) -    async def infraction_search_group(self, ctx: Context, query: InfractionSearchQuery) -> None: +    async def infraction_search_group(self, ctx: Context, query: t.Union[UserMention, Snowflake, str]) -> None:          """Searches for infractions in the database.""" -        if isinstance(query, discord.User): -            await ctx.invoke(self.search_user, query) +        if isinstance(query, int): +            await self.search_user(ctx, discord.Object(query))          else: -            await ctx.invoke(self.search_reason, query) +            await self.search_reason(ctx, query)      @infraction_search_group.command(name="user", aliases=("member", "id"))      async def search_user(self, ctx: Context, user: t.Union[discord.User, proxy_user]) -> None:          """Search for infractions by member."""          infraction_list = await self.bot.api_client.get( -            'bot/infractions', +            'bot/infractions/expanded',              params={'user__id': str(user.id)}          ) + +        user = self.bot.get_user(user.id) +        if not user and infraction_list: +            # Use the user data retrieved from the DB for the username. +            user = infraction_list[0]["user"] +            user = escape_markdown(user["name"]) + f"#{user['discriminator']:04}" +          embed = discord.Embed(              title=f"Infractions for {user} ({len(infraction_list)} total)",              colour=discord.Colour.orange() @@ -204,7 +207,7 @@ class ModManagement(commands.Cog):      async def search_reason(self, ctx: Context, reason: str) -> None:          """Search for infractions by their reason. Use Re2 for matching."""          infraction_list = await self.bot.api_client.get( -            'bot/infractions', +            'bot/infractions/expanded',              params={'search': reason}          )          embed = discord.Embed( @@ -220,7 +223,7 @@ class ModManagement(commands.Cog):          self,          ctx: Context,          embed: discord.Embed, -        infractions: t.Iterable[_utils.Infraction] +        infractions: t.Iterable[t.Dict[str, t.Any]]      ) -> None:          """Send a paginated embed of infractions for the specified user."""          if not infractions: @@ -241,37 +244,43 @@ class ModManagement(commands.Cog):              max_size=1000          ) -    def infraction_to_string(self, infraction: _utils.Infraction) -> str: +    def infraction_to_string(self, infraction: t.Dict[str, t.Any]) -> str:          """Convert the infraction object to a string representation.""" -        actor_id = infraction["actor"] -        guild = self.bot.get_guild(constants.Guild.id) -        actor = guild.get_member(actor_id)          active = infraction["active"] -        user_id = infraction["user"] -        hidden = infraction["hidden"] +        user = infraction["user"] +        expires_at = infraction["expires_at"]          created = time.format_infraction(infraction["inserted_at"]) +        # Format the user string. +        if user_obj := self.bot.get_user(user["id"]): +            # The user is in the cache. +            user_str = messages.format_user(user_obj) +        else: +            # Use the user data retrieved from the DB. +            name = escape_markdown(user['name']) +            user_str = f"<@{user['id']}> ({name}#{user['discriminator']:04})" +          if active: -            remaining = time.until_expiration(infraction["expires_at"]) or "Expired" +            remaining = time.until_expiration(expires_at) or "Expired"          else:              remaining = "Inactive" -        if infraction["expires_at"] is None: +        if expires_at is None:              expires = "*Permanent*"          else:              date_from = datetime.strptime(created, time.INFRACTION_FORMAT) -            expires = time.format_infraction_with_duration(infraction["expires_at"], date_from) +            expires = time.format_infraction_with_duration(expires_at, date_from)          lines = textwrap.dedent(f"""              {"**===============**" if active else "==============="}              Status: {"__**Active**__" if active else "Inactive"} -            User: {self.bot.get_user(user_id)} (`{user_id}`) +            User: {user_str}              Type: **{infraction["type"]}** -            Shadow: {hidden} +            Shadow: {infraction["hidden"]}              Created: {created}              Expires: {expires}              Remaining: {remaining} -            Actor: {actor.mention if actor else actor_id} +            Actor: <@{infraction["actor"]["id"]}>              ID: `{infraction["id"]}`              Reason: {infraction["reason"] or "*None*"}              {"**===============**" if active else "==============="} @@ -282,17 +291,11 @@ class ModManagement(commands.Cog):      # endregion      # This cannot be static (must have a __func__ attribute). -    def cog_check(self, ctx: Context) -> bool: +    async def cog_check(self, ctx: Context) -> bool:          """Only allow moderators inside moderator channels to invoke the commands in this cog."""          checks = [ -            with_role_check(ctx, *constants.MODERATION_ROLES), -            in_whitelist_check( -                ctx, -                channels=constants.MODERATION_CHANNELS, -                categories=[constants.Categories.modmail], -                redirect=None, -                fail_silently=True, -            ) +            await commands.has_any_role(*constants.MODERATION_ROLES).predicate(ctx), +            is_mod_channel(ctx.channel)          ]          return all(checks) diff --git a/bot/exts/moderation/infraction/superstarify.py b/bot/exts/moderation/infraction/superstarify.py index a4e78c4d3..adfe42fcd 100644 --- a/bot/exts/moderation/infraction/superstarify.py +++ b/bot/exts/moderation/infraction/superstarify.py @@ -6,14 +6,15 @@ import typing as t  from pathlib import Path  from discord import Colour, Embed, Member -from discord.ext.commands import Cog, Context, command +from discord.ext.commands import Cog, Context, command, has_any_role +from discord.utils import escape_markdown  from bot import constants  from bot.bot import Bot  from bot.converters import Expiry  from bot.exts.moderation.infraction import _utils  from bot.exts.moderation.infraction._scheduler import InfractionScheduler -from bot.utils.checks import with_role_check +from bot.utils.messages import format_user  from bot.utils.time import format_infraction  log = logging.getLogger(__name__) @@ -134,11 +135,11 @@ class Superstarify(InfractionScheduler, Cog):              return          # Post the infraction to the API -        reason = reason or f"old nick: {member.display_name}" +        old_nick = member.display_name +        reason = reason or f"old nick: {old_nick}"          infraction = await _utils.post_infraction(ctx, member, "superstar", reason, duration, active=True)          id_ = infraction["id"] -        old_nick = member.display_name          forced_nick = self.get_nick(id_, member.id)          expiry_str = format_infraction(infraction["expires_at"]) @@ -148,6 +149,9 @@ class Superstarify(InfractionScheduler, Cog):          await member.edit(nick=forced_nick, reason=reason)          self.schedule_expiration(infraction) +        old_nick = escape_markdown(old_nick) +        forced_nick = escape_markdown(forced_nick) +          # Send a DM to the user to notify them of their new infraction.          await _utils.notify_infraction(              user=member, @@ -181,8 +185,8 @@ class Superstarify(InfractionScheduler, Cog):              title="Member achieved superstardom",              thumbnail=member.avatar_url_as(static_format="png"),              text=textwrap.dedent(f""" -                Member: {member.mention} (`{member.id}`) -                Actor: {ctx.message.author} +                Member: {member.mention} +                Actor: {ctx.message.author.mention}                  Expires: {expiry_str}                  Old nickname: `{old_nick}`                  New nickname: `{forced_nick}` @@ -221,7 +225,7 @@ class Superstarify(InfractionScheduler, Cog):          )          return { -            "Member": f"{user.mention}(`{user.id}`)", +            "Member": format_user(user),              "DM": "Sent" if notified else "**Failed**"          } @@ -234,9 +238,9 @@ 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: diff --git a/bot/exts/moderation/modlog.py b/bot/exts/moderation/modlog.py index b0d9b5b2b..b01de0ee3 100644 --- a/bot/exts/moderation/modlog.py +++ b/bot/exts/moderation/modlog.py @@ -12,10 +12,10 @@ from deepdiff import DeepDiff  from discord import Colour  from discord.abc import GuildChannel  from discord.ext.commands import Cog, Context -from discord.utils import escape_markdown  from bot.bot import Bot  from bot.constants import Categories, Channels, Colours, Emojis, Event, Guild as GuildConstant, Icons, URLs +from bot.utils.messages import format_user  from bot.utils.time import humanize_delta  log = logging.getLogger(__name__) @@ -63,7 +63,7 @@ class ModLog(Cog, name="ModLog"):                          'id': message.id,                          'author': message.author.id,                          'channel_id': message.channel.id, -                        'content': message.content, +                        'content': message.content.replace("\0", ""),  # Null chars cause 400.                          'embeds': [embed.to_dict() for embed in message.embeds],                          'attachments': attachment,                      } @@ -396,7 +396,7 @@ class ModLog(Cog, name="ModLog"):          await self.send_log_message(              Icons.user_ban, Colours.soft_red, -            "User banned", f"{member} (`{member.id}`)", +            "User banned", format_user(member),              thumbnail=member.avatar_url_as(static_format="png"),              channel_id=Channels.user_log          ) @@ -407,12 +407,10 @@ class ModLog(Cog, name="ModLog"):          if member.guild.id != GuildConstant.id:              return -        member_str = escape_markdown(str(member)) -        message = f"{member_str} (`{member.id}`)"          now = datetime.utcnow()          difference = abs(relativedelta(now, member.created_at)) -        message += "\n\n**Account age:** " + humanize_delta(difference) +        message = format_user(member) + "\n\n**Account age:** " + humanize_delta(difference)          if difference.days < 1 and difference.months < 1 and difference.years < 1:  # New user account!              message = f"{Emojis.new} {message}" @@ -434,10 +432,9 @@ class ModLog(Cog, name="ModLog"):              self._ignored[Event.member_remove].remove(member.id)              return -        member_str = escape_markdown(str(member))          await self.send_log_message(              Icons.sign_out, Colours.soft_red, -            "User left", f"{member_str} (`{member.id}`)", +            "User left", format_user(member),              thumbnail=member.avatar_url_as(static_format="png"),              channel_id=Channels.user_log          ) @@ -452,10 +449,9 @@ class ModLog(Cog, name="ModLog"):              self._ignored[Event.member_unban].remove(member.id)              return -        member_str = escape_markdown(str(member))          await self.send_log_message(              Icons.user_unban, Colour.blurple(), -            "User unbanned", f"{member_str} (`{member.id}`)", +            "User unbanned", format_user(member),              thumbnail=member.avatar_url_as(static_format="png"),              channel_id=Channels.mod_log          ) @@ -515,8 +511,7 @@ class ModLog(Cog, name="ModLog"):          for item in sorted(changes):              message += f"{Emojis.bullet} {item}\n" -        member_str = escape_markdown(str(after)) -        message = f"**{member_str}** (`{after.id}`)\n{message}" +        message = f"{format_user(after)}\n{message}"          await self.send_log_message(              icon_url=Icons.user_update, @@ -549,17 +544,16 @@ class ModLog(Cog, name="ModLog"):          if author.bot:              return -        author_str = escape_markdown(str(author))          if channel.category:              response = ( -                f"**Author:** {author_str} (`{author.id}`)\n" +                f"**Author:** {format_user(author)}\n"                  f"**Channel:** {channel.category}/#{channel.name} (`{channel.id}`)\n"                  f"**Message ID:** `{message.id}`\n"                  "\n"              )          else:              response = ( -                f"**Author:** {author_str} (`{author.id}`)\n" +                f"**Author:** {format_user(author)}\n"                  f"**Channel:** #{channel.name} (`{channel.id}`)\n"                  f"**Message ID:** `{message.id}`\n"                  "\n" @@ -645,9 +639,6 @@ class ModLog(Cog, name="ModLog"):          if msg_before.content == msg_after.content:              return -        author = msg_before.author -        author_str = escape_markdown(str(author)) -          channel = msg_before.channel          channel_name = f"{channel.category}/#{channel.name}" if channel.category else f"#{channel.name}" @@ -679,7 +670,7 @@ class ModLog(Cog, name="ModLog"):                  content_after.append(sub)          response = ( -            f"**Author:** {author_str} (`{author.id}`)\n" +            f"**Author:** {format_user(msg_before.author)}\n"              f"**Channel:** {channel_name} (`{channel.id}`)\n"              f"**Message ID:** `{msg_before.id}`\n"              "\n" @@ -731,12 +722,11 @@ class ModLog(Cog, name="ModLog"):              self._cached_edits.remove(event.message_id)              return -        author = message.author          channel = message.channel          channel_name = f"{channel.category}/#{channel.name}" if channel.category else f"#{channel.name}"          before_response = ( -            f"**Author:** {author} (`{author.id}`)\n" +            f"**Author:** {format_user(message.author)}\n"              f"**Channel:** {channel_name} (`{channel.id}`)\n"              f"**Message ID:** `{message.id}`\n"              "\n" @@ -744,7 +734,7 @@ class ModLog(Cog, name="ModLog"):          )          after_response = ( -            f"**Author:** {author} (`{author.id}`)\n" +            f"**Author:** {format_user(message.author)}\n"              f"**Channel:** {channel_name} (`{channel.id}`)\n"              f"**Message ID:** `{message.id}`\n"              "\n" @@ -822,9 +812,8 @@ class ModLog(Cog, name="ModLog"):          if not changes:              return -        member_str = escape_markdown(str(member))          message = "\n".join(f"{Emojis.bullet} {item}" for item in sorted(changes)) -        message = f"**{member_str}** (`{member.id}`)\n{message}" +        message = f"{format_user(member)}\n{message}"          await self.send_log_message(              icon_url=icon, diff --git a/bot/exts/moderation/silence.py b/bot/exts/moderation/silence.py index 4af87c724..e6712b3b6 100644 --- a/bot/exts/moderation/silence.py +++ b/bot/exts/moderation/silence.py @@ -1,8 +1,11 @@ -import asyncio +import json  import logging  from contextlib import suppress +from datetime import datetime, timedelta, timezone +from operator import attrgetter  from typing import Optional +from async_rediscache import RedisCache  from discord import TextChannel  from discord.ext import commands, tasks  from discord.ext.commands import Context @@ -10,11 +13,25 @@ from discord.ext.commands import Context  from bot.bot import Bot  from bot.constants import Channels, Emojis, Guild, MODERATION_ROLES, Roles  from bot.converters import HushDurationConverter -from bot.utils.checks import with_role_check +from bot.utils.lock import LockedResourceError, lock_arg  from bot.utils.scheduling import Scheduler  log = logging.getLogger(__name__) +LOCK_NAMESPACE = "silence" + +MSG_SILENCE_FAIL = f"{Emojis.cross_mark} current channel is already silenced." +MSG_SILENCE_PERMANENT = f"{Emojis.check_mark} silenced current channel indefinitely." +MSG_SILENCE_SUCCESS = f"{Emojis.check_mark} silenced current channel for {{duration}} minute(s)." + +MSG_UNSILENCE_FAIL = f"{Emojis.cross_mark} current channel was not silenced." +MSG_UNSILENCE_MANUAL = ( +    f"{Emojis.cross_mark} current channel was not unsilenced because the current overwrites were " +    f"set manually or the cache was prematurely cleared. " +    f"Please edit the overwrites manually to unsilence." +) +MSG_UNSILENCE_SUCCESS = f"{Emojis.check_mark} unsilenced current channel." +  class SilenceNotifier(tasks.Loop):      """Loop notifier for posting notices to `alert_channel` containing added channels.""" @@ -57,25 +74,32 @@ class SilenceNotifier(tasks.Loop):  class Silence(commands.Cog):      """Commands for stopping channel messages for `verified` role in a channel.""" +    # Maps muted channel IDs to their previous overwrites for send_message and add_reactions. +    # Overwrites are stored as JSON. +    previous_overwrites = RedisCache() + +    # Maps muted channel IDs to POSIX timestamps of when they'll be unsilenced. +    # A timestamp equal to -1 means it's indefinite. +    unsilence_timestamps = RedisCache() +      def __init__(self, bot: Bot):          self.bot = bot          self.scheduler = Scheduler(self.__class__.__name__) -        self.muted_channels = set() -        self._get_instance_vars_task = self.bot.loop.create_task(self._get_instance_vars()) -        self._get_instance_vars_event = asyncio.Event() +        self._init_task = self.bot.loop.create_task(self._async_init()) -    async def _get_instance_vars(self) -> None: -        """Get instance variables after they're available to get from the guild.""" +    async def _async_init(self) -> None: +        """Set instance attributes once the guild is available and reschedule unsilences."""          await self.bot.wait_until_guild_available() +          guild = self.bot.get_guild(Guild.id)          self._verified_role = guild.get_role(Roles.verified)          self._mod_alerts_channel = self.bot.get_channel(Channels.mod_alerts) -        self._mod_log_channel = self.bot.get_channel(Channels.mod_log) -        self.notifier = SilenceNotifier(self._mod_log_channel) -        self._get_instance_vars_event.set() +        self.notifier = SilenceNotifier(self.bot.get_channel(Channels.mod_log)) +        await self._reschedule()      @commands.command(aliases=("hush",)) +    @lock_arg(LOCK_NAMESPACE, "ctx", attrgetter("channel"), raise_error=True)      async def silence(self, ctx: Context, duration: HushDurationConverter = 10) -> None:          """          Silence the current channel for `duration` minutes or `forever`. @@ -83,18 +107,25 @@ class Silence(commands.Cog):          Duration is capped at 15 minutes, passing forever makes the silence indefinite.          Indefinitely silenced channels get added to a notifier which posts notices every 15 minutes from the start.          """ -        await self._get_instance_vars_event.wait() -        log.debug(f"{ctx.author} is silencing channel #{ctx.channel}.") -        if not await self._silence(ctx.channel, persistent=(duration is None), duration=duration): -            await ctx.send(f"{Emojis.cross_mark} current channel is already silenced.") -            return -        if duration is None: -            await ctx.send(f"{Emojis.check_mark} silenced current channel indefinitely.") +        await self._init_task + +        channel_info = f"#{ctx.channel} ({ctx.channel.id})" +        log.debug(f"{ctx.author} is silencing channel {channel_info}.") + +        if not await self._set_silence_overwrites(ctx.channel): +            log.info(f"Tried to silence channel {channel_info} but the channel was already silenced.") +            await ctx.send(MSG_SILENCE_FAIL)              return -        await ctx.send(f"{Emojis.check_mark} silenced current channel for {duration} minute(s).") +        await self._schedule_unsilence(ctx, duration) -        self.scheduler.schedule_later(duration * 60, ctx.channel.id, ctx.invoke(self.unsilence)) +        if duration is None: +            self.notifier.add_channel(ctx.channel) +            log.info(f"Silenced {channel_info} indefinitely.") +            await ctx.send(MSG_SILENCE_PERMANENT) +        else: +            log.info(f"Silenced {channel_info} for {duration} minute(s).") +            await ctx.send(MSG_SILENCE_SUCCESS.format(duration=duration))      @commands.command(aliases=("unhush",))      async def unsilence(self, ctx: Context) -> None: @@ -103,66 +134,120 @@ class Silence(commands.Cog):          If the channel was silenced indefinitely, notifications for the channel will stop.          """ -        await self._get_instance_vars_event.wait() +        await self._init_task          log.debug(f"Unsilencing channel #{ctx.channel} from {ctx.author}'s command.") -        if not await self._unsilence(ctx.channel): -            await ctx.send(f"{Emojis.cross_mark} current channel was not silenced.") +        await self._unsilence_wrapper(ctx.channel) + +    @lock_arg(LOCK_NAMESPACE, "channel", raise_error=True) +    async def _unsilence_wrapper(self, channel: TextChannel) -> None: +        """Unsilence `channel` and send a success/failure message.""" +        if not await self._unsilence(channel): +            overwrite = channel.overwrites_for(self._verified_role) +            if overwrite.send_messages is False or overwrite.add_reactions is False: +                await channel.send(MSG_UNSILENCE_MANUAL) +            else: +                await channel.send(MSG_UNSILENCE_FAIL)          else: -            await ctx.send(f"{Emojis.check_mark} unsilenced current channel.") +            await channel.send(MSG_UNSILENCE_SUCCESS) -    async def _silence(self, channel: TextChannel, persistent: bool, duration: Optional[int]) -> bool: -        """ -        Silence `channel` for `self._verified_role`. +    async def _set_silence_overwrites(self, channel: TextChannel) -> bool: +        """Set silence permission overwrites for `channel` and return True if successful.""" +        overwrite = channel.overwrites_for(self._verified_role) +        prev_overwrites = dict(send_messages=overwrite.send_messages, add_reactions=overwrite.add_reactions) -        If `persistent` is `True` add `channel` to notifier. -        `duration` is only used for logging; if None is passed `persistent` should be True to not log None. -        Return `True` if channel permissions were changed, `False` otherwise. -        """ -        current_overwrite = channel.overwrites_for(self._verified_role) -        if current_overwrite.send_messages is False: -            log.info(f"Tried to silence channel #{channel} ({channel.id}) but the channel was already silenced.") +        if channel.id in self.scheduler or all(val is False for val in prev_overwrites.values()):              return False -        await channel.set_permissions(self._verified_role, **dict(current_overwrite, send_messages=False)) -        self.muted_channels.add(channel) -        if persistent: -            log.info(f"Silenced #{channel} ({channel.id}) indefinitely.") -            self.notifier.add_channel(channel) -            return True - -        log.info(f"Silenced #{channel} ({channel.id}) for {duration} minute(s).") + +        overwrite.update(send_messages=False, add_reactions=False) +        await channel.set_permissions(self._verified_role, overwrite=overwrite) +        await self.previous_overwrites.set(channel.id, json.dumps(prev_overwrites)) +          return True +    async def _schedule_unsilence(self, ctx: Context, duration: Optional[int]) -> None: +        """Schedule `ctx.channel` to be unsilenced if `duration` is not None.""" +        if duration is None: +            await self.unsilence_timestamps.set(ctx.channel.id, -1) +        else: +            self.scheduler.schedule_later(duration * 60, ctx.channel.id, ctx.invoke(self.unsilence)) +            unsilence_time = datetime.now(tz=timezone.utc) + timedelta(minutes=duration) +            await self.unsilence_timestamps.set(ctx.channel.id, unsilence_time.timestamp()) +      async def _unsilence(self, channel: TextChannel) -> bool:          """          Unsilence `channel`. -        Check if `channel` is silenced through a `PermissionOverwrite`, -        if it is unsilence it and remove it from the notifier. +        If `channel` has a silence task scheduled or has its previous overwrites cached, unsilence +        it, cancel the task, and remove it from the notifier. Notify admins if it has a task but +        not cached overwrites. +          Return `True` if channel permissions were changed, `False` otherwise.          """ -        current_overwrite = channel.overwrites_for(self._verified_role) -        if current_overwrite.send_messages is False: -            await channel.set_permissions(self._verified_role, **dict(current_overwrite, send_messages=None)) -            log.info(f"Unsilenced channel #{channel} ({channel.id}).") -            self.scheduler.cancel(channel.id) -            self.notifier.remove_channel(channel) -            self.muted_channels.discard(channel) -            return True -        log.info(f"Tried to unsilence channel #{channel} ({channel.id}) but the channel was not silenced.") -        return False +        prev_overwrites = await self.previous_overwrites.get(channel.id) +        if channel.id not in self.scheduler and prev_overwrites is None: +            log.info(f"Tried to unsilence channel #{channel} ({channel.id}) but the channel was not silenced.") +            return False + +        overwrite = channel.overwrites_for(self._verified_role) +        if prev_overwrites is None: +            log.info(f"Missing previous overwrites for #{channel} ({channel.id}); defaulting to None.") +            overwrite.update(send_messages=None, add_reactions=None) +        else: +            overwrite.update(**json.loads(prev_overwrites)) + +        await channel.set_permissions(self._verified_role, overwrite=overwrite) +        log.info(f"Unsilenced channel #{channel} ({channel.id}).") + +        self.scheduler.cancel(channel.id) +        self.notifier.remove_channel(channel) +        await self.previous_overwrites.delete(channel.id) +        await self.unsilence_timestamps.delete(channel.id) + +        if prev_overwrites is None: +            await self._mod_alerts_channel.send( +                f"<@&{Roles.admins}> Restored overwrites with default values after unsilencing " +                f"{channel.mention}. Please check that the `Send Messages` and `Add Reactions` " +                f"overwrites for {self._verified_role.mention} are at their desired values." +            ) + +        return True + +    async def _reschedule(self) -> None: +        """Reschedule unsilencing of active silences and add permanent ones to the notifier.""" +        for channel_id, timestamp in await self.unsilence_timestamps.items(): +            channel = self.bot.get_channel(channel_id) +            if channel is None: +                log.info(f"Can't reschedule silence for {channel_id}: channel not found.") +                continue + +            if timestamp == -1: +                log.info(f"Adding permanent silence for #{channel} ({channel.id}) to the notifier.") +                self.notifier.add_channel(channel) +                continue + +            dt = datetime.fromtimestamp(timestamp, tz=timezone.utc) +            delta = (dt - datetime.now(tz=timezone.utc)).total_seconds() +            if delta <= 0: +                # Suppress the error since it's not being invoked by a user via the command. +                with suppress(LockedResourceError): +                    await self._unsilence_wrapper(channel) +            else: +                log.info(f"Rescheduling silence for #{channel} ({channel.id}).") +                self.scheduler.schedule_later(delta, channel_id, self._unsilence_wrapper(channel))      def cog_unload(self) -> None: -        """Send alert with silenced channels and cancel scheduled tasks on unload.""" -        self.scheduler.cancel_all() -        if self.muted_channels: -            channels_string = ''.join(channel.mention for channel in self.muted_channels) -            message = f"<@&{Roles.moderators}> channels left silenced on cog unload: {channels_string}" -            asyncio.create_task(self._mod_alerts_channel.send(message)) +        """Cancel the init task and scheduled tasks.""" +        # It's important to wait for _init_task (specifically for _reschedule) to be cancelled +        # before cancelling scheduled tasks. Otherwise, it's possible for _reschedule to schedule +        # more tasks after cancel_all has finished, despite _init_task.cancel being called first. +        # This is cause cancel() on its own doesn't block until the task is cancelled. +        self._init_task.cancel() +        self._init_task.add_done_callback(lambda _: self.scheduler.cancel_all())      # This cannot be static (must have a __func__ attribute). -    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: diff --git a/bot/exts/moderation/slowmode.py b/bot/exts/moderation/slowmode.py index 1d055afac..efd862aa5 100644 --- a/bot/exts/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 index 53fa0730b..c599156d0 100644 --- a/bot/exts/moderation/verification.py +++ b/bot/exts/moderation/verification.py @@ -5,27 +5,32 @@ 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 +from discord.ext.commands import Cog, Context, command, group, has_any_role  from discord.utils import snowflake_time  from bot import constants +from bot.api import ResponseCodeError  from bot.bot import Bot -from bot.decorators import in_whitelist, with_role, without_role +from bot.decorators import has_no_roles, in_whitelist  from bot.exts.moderation.modlog import ModLog -from bot.utils.checks import InWhitelistCheckFailure, without_role_check -from bot.utils.redis_cache import RedisCache +from bot.utils.checks import InWhitelistCheckFailure, has_no_roles_check +from bot.utils.messages import format_user  log = logging.getLogger(__name__)  # Sent via DMs once user joins the guild  ON_JOIN_MESSAGE = f""" -Hello! Welcome to Python Discord! +Welcome to Python Discord! -As a new user, you have read-only access to a few select channels to give you a taste of what our server is like. +To show you what kind of community we are, we've created this video: +https://youtu.be/ZH26PuX3re0 -In order to see the rest of the channels and to send messages, you first have to accept our rules. To do so, \ -please visit <#{constants.Channels.verification}>. Thank you! +As a new user, you have read-only access to a few select channels to give you a taste of what our server is like. \ +In order to see the rest of the channels and to send messages, you first have to accept our rules. + +Please visit <#{constants.Channels.verification}> to get started. Thank you!  """  # Sent via DMs once user verifies @@ -49,6 +54,23 @@ If you'd like to unsubscribe from the announcement notifications, simply send `!  <#{constants.Channels.bot_commands}>.  """ +ALTERNATE_VERIFIED_MESSAGE = f""" +Thanks for accepting our rules! + +You can find a copy of our rules for reference at <https://pythondiscord.com/pages/rules>. + +Additionally, if you'd like to receive notifications for the announcements \ +we post in <#{constants.Channels.announcements}> +from time to time, you can send `!subscribe` to <#{constants.Channels.bot_commands}> at any time \ +to assign yourself the **Announcements** role. We'll mention this role every time we make an announcement. + +If you'd like to unsubscribe from the announcement notifications, simply send `!unsubscribe` to \ +<#{constants.Channels.bot_commands}>. + +To introduce you to our community, we've made the following video: +https://youtu.be/ZH26PuX3re0 +""" +  # Sent via DMs to users kicked for failing to verify  KICKED_MESSAGE = f"""  Hi! You have been automatically kicked from Python Discord as you have failed to accept our rules \ @@ -105,6 +127,25 @@ def is_verified(member: discord.Member) -> bool:      return len(set(member.roles) - unverified_roles) > 0 +async def safe_dm(coro: t.Coroutine) -> None: +    """ +    Execute `coro` ignoring disabled DM warnings. + +    The 50_0007 error code indicates that the target user does not accept DMs. +    As it turns out, this error code can appear on both 400 and 403 statuses, +    we therefore catch any Discord exception. + +    If the request fails on any other error code, the exception propagates, +    and must be handled by the caller. +    """ +    try: +        await coro +    except discord.HTTPException as discord_exc: +        log.trace(f"DM dispatch failed on status {discord_exc.status} with code: {discord_exc.code}") +        if discord_exc.code != 50_007:  # If any reason other than disabled DMs +            raise + +  class Verification(Cog):      """      User verification and role management. @@ -133,6 +174,9 @@ class Verification(Cog):      # ]      task_cache = RedisCache() +    # Create a cache for storing recipients of the alternate welcome DM. +    member_gating_cache = RedisCache() +      def __init__(self, bot: Bot) -> None:          """Start internal tasks."""          self.bot = bot @@ -285,7 +329,7 @@ class Verification(Cog):          Returns the amount of successful requests. Failed requests are logged at info level.          """ -        log.info(f"Sending {len(members)} requests") +        log.trace(f"Sending {len(members)} requests")          n_success, bad_statuses = 0, set()          for progress, member in enumerate(members, start=1): @@ -312,6 +356,28 @@ class Verification(Cog):          return n_success +    async def _add_kick_note(self, member: discord.Member) -> None: +        """ +        Post a note regarding `member` being kicked to site. + +        Allows keeping track of kicked members for auditing purposes. +        """ +        payload = { +            "active": False, +            "actor": self.bot.user.id,  # Bot actions this autonomously +            "expires_at": None, +            "hidden": True, +            "reason": "Verification kick", +            "type": "note", +            "user": member.id, +        } + +        log.trace(f"Posting kick note for member {member} ({member.id})") +        try: +            await self.bot.api_client.post("bot/infractions", json=payload) +        except ResponseCodeError as api_exc: +            log.warning("Failed to post kick note", exc_info=api_exc) +      async def _kick_members(self, members: t.Collection[discord.Member]) -> int:          """          Kick `members` from the PyDis guild. @@ -326,12 +392,11 @@ class Verification(Cog):          async def kick_request(member: discord.Member) -> None:              """Send `KICKED_MESSAGE` to `member` and kick them from the guild."""              try: -                await member.send(KICKED_MESSAGE) -            except discord.Forbidden as exc_403: -                log.trace(f"DM dispatch failed on 403 error with code: {exc_403.code}") -                if exc_403.code != 50_007:  # 403 raised for any other reason than disabled DMs -                    raise StopExecution(reason=exc_403) +                await safe_dm(member.send(KICKED_MESSAGE))  # Suppress disabled DMs +            except discord.HTTPException as suspicious_exception: +                raise StopExecution(reason=suspicious_exception)              await member.kick(reason=f"User has not verified in {constants.Verification.kicked_after} days") +            await self._add_kick_note(member)          n_kicked = await self._send_requests(members, kick_request, Limit(batch_size=2, sleep_secs=1))          self.bot.stats.incr("verification.kicked", count=n_kicked) @@ -498,9 +563,48 @@ class Verification(Cog):          if member.guild.id != constants.Guild.id:              return  # Only listen for PyDis events +        raw_member = await self.bot.http.get_member(member.guild.id, member.id) + +        # If the user has the is_pending flag set, they will be using the alternate +        # gate and will not need a welcome DM with verification instructions. +        # We will send them an alternate DM once they verify with the welcome +        # video. +        if raw_member.get("is_pending"): +            await self.member_gating_cache.set(member.id, True) + +            # TODO: Temporary, remove soon after asking joe. +            await self.mod_log.send_log_message( +                icon_url=self.bot.user.avatar_url, +                colour=discord.Colour.blurple(), +                title="New native gated user", +                channel_id=constants.Channels.user_log, +                text=f"<@{member.id}> ({member.id})", +            ) + +            return +          log.trace(f"Sending on join message to new member: {member.id}") -        with suppress(discord.Forbidden): -            await member.send(ON_JOIN_MESSAGE) +        try: +            await safe_dm(member.send(ON_JOIN_MESSAGE)) +        except discord.HTTPException: +            log.exception("DM dispatch failed on unexpected error code") + +    @Cog.listener() +    async def on_member_update(self, before: discord.Member, after: discord.Member) -> None: +        """Check if we need to send a verification DM to a gated user.""" +        before_roles = [role.id for role in before.roles] +        after_roles = [role.id for role in after.roles] + +        if constants.Roles.verified not in before_roles and constants.Roles.verified in after_roles: +            if await self.member_gating_cache.pop(after.id): +                try: +                    # If the member has not received a DM from our !accept command +                    # and has gone through the alternate gating system we should send +                    # our alternate welcome DM which includes info such as our welcome +                    # video. +                    await safe_dm(after.send(ALTERNATE_VERIFIED_MESSAGE)) +                except discord.HTTPException: +                    log.exception("DM dispatch failed on unexpected error code")      @Cog.listener()      async def on_message(self, message: discord.Message) -> None: @@ -525,7 +629,7 @@ class Verification(Cog):              )              embed_text = ( -                f"{message.author.mention} sent a message in " +                f"{format_user(message.author)} sent a message in "                  f"{message.channel.mention} that contained user and/or role mentions."                  f"\n\n**Original message:**\n>>> {message.content}"              ) @@ -568,7 +672,7 @@ class Verification(Cog):      # endregion      # region: task management commands -    @with_role(*constants.MODERATION_ROLES) +    @has_any_role(*constants.MODERATION_ROLES)      @group(name="verification")      async def verification_group(self, ctx: Context) -> None:          """Manage internal verification tasks.""" @@ -653,7 +757,7 @@ class Verification(Cog):          self.bot.stats.incr(f"verification.{category}")      @command(name='accept', aliases=('verify', 'verified', 'accepted'), hidden=True) -    @without_role(constants.Roles.verified) +    @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.""" @@ -667,9 +771,9 @@ class Verification(Cog):              await ctx.author.remove_roles(discord.Object(constants.Roles.unverified))          try: -            await ctx.author.send(VERIFIED_MESSAGE) -        except discord.Forbidden: -            log.info(f"Sending welcome message failed for {ctx.author}.") +            await safe_dm(ctx.author.send(VERIFIED_MESSAGE)) +        except discord.HTTPException: +            log.exception(f"Sending welcome message failed for {ctx.author}.")          finally:              log.trace(f"Deleting accept message by {ctx.author}.")              with suppress(discord.NotFound): @@ -736,9 +840,10 @@ class Verification(Cog):              error.handled = True      @staticmethod -    def bot_check(ctx: Context) -> bool: +    async 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): +        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 diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py new file mode 100644 index 000000000..c2743e136 --- /dev/null +++ b/bot/exts/moderation/voice_gate.py @@ -0,0 +1,168 @@ +import asyncio +import logging +from contextlib import suppress +from datetime import datetime, timedelta + +import discord +from dateutil import parser +from discord import Colour +from discord.ext.commands import Cog, Context, command + +from bot.api import ResponseCodeError +from bot.bot import Bot +from bot.constants import Channels, Event, MODERATION_ROLES, Roles, VoiceGate as GateConf +from bot.decorators import has_no_roles, in_whitelist +from bot.exts.moderation.modlog import ModLog +from bot.utils.checks import InWhitelistCheckFailure + +log = logging.getLogger(__name__) + +FAILED_MESSAGE = ( +    """You are not currently eligible to use voice inside Python Discord for the following reasons:\n\n{reasons}""" +) + +MESSAGE_FIELD_MAP = { +    "verified_at": f"have been verified for less than {GateConf.minimum_days_verified} days", +    "voice_banned": "have an active voice ban infraction", +    "total_messages": f"have sent less than {GateConf.minimum_messages} messages", +} + + +class VoiceGate(Cog): +    """Voice channels verification management.""" + +    def __init__(self, bot: Bot): +        self.bot = bot + +    @property +    def mod_log(self) -> ModLog: +        """Get the currently loaded ModLog cog instance.""" +        return self.bot.get_cog("ModLog") + +    @command(aliases=('voiceverify',)) +    @has_no_roles(Roles.voice_verified) +    @in_whitelist(channels=(Channels.voice_gate,), redirect=None) +    async def voice_verify(self, ctx: Context, *_) -> None: +        """ +        Apply to be able to use voice within the Discord server. + +        In order to use voice you must meet all three of the following criteria: +        - You must have over a certain number of messages within the Discord server +        - You must have accepted our rules over a certain number of days ago +        - You must not be actively banned from using our voice channels +        """ +        try: +            data = await self.bot.api_client.get(f"bot/users/{ctx.author.id}/metricity_data") +        except ResponseCodeError as e: +            if e.status == 404: +                embed = discord.Embed( +                    title="Not found", +                    description=( +                        "We were unable to find user data for you. " +                        "Please try again shortly, " +                        "if this problem persists please contact the server staff through Modmail.", +                    ), +                    color=Colour.red() +                ) +                log.info(f"Unable to find Metricity data about {ctx.author} ({ctx.author.id})") +            else: +                embed = discord.Embed( +                    title="Unexpected response", +                    description=( +                        "We encountered an error while attempting to find data for your user. " +                        "Please try again and let us know if the problem persists." +                    ), +                    color=Colour.red() +                ) +                log.warning(f"Got response code {e.status} while trying to get {ctx.author.id} Metricity data.") + +            await ctx.author.send(embed=embed) +            return + +        # Pre-parse this for better code style +        if data["verified_at"] is not None: +            data["verified_at"] = parser.isoparse(data["verified_at"]) +        else: +            data["verified_at"] = datetime.utcnow() - timedelta(days=3) + +        checks = { +            "verified_at": data["verified_at"] > datetime.utcnow() - timedelta(days=GateConf.minimum_days_verified), +            "total_messages": data["total_messages"] < GateConf.minimum_messages, +            "voice_banned": data["voice_banned"] +        } +        failed = any(checks.values()) +        failed_reasons = [MESSAGE_FIELD_MAP[key] for key, value in checks.items() if value is True] +        [self.bot.stats.incr(f"voice_gate.failed.{key}") for key, value in checks.items() if value is True] + +        if failed: +            embed = discord.Embed( +                title="Voice Gate failed", +                description=FAILED_MESSAGE.format(reasons="\n".join(f'• You {reason}.' for reason in failed_reasons)), +                color=Colour.red() +            ) +            try: +                await ctx.author.send(embed=embed) +                await ctx.send(f"{ctx.author}, please check your DMs.") +            except discord.Forbidden: +                await ctx.channel.send(ctx.author.mention, embed=embed) +            return + +        self.mod_log.ignore(Event.member_update, ctx.author.id) +        embed = discord.Embed( +            title="Voice gate passed", +            description="You have been granted permission to use voice channels in Python Discord.", +            color=Colour.green() +        ) + +        if ctx.author.voice: +            embed.description += "\n\nPlease reconnect to your voice channel to be granted your new permissions." + +        try: +            await ctx.author.send(embed=embed) +            await ctx.send(f"{ctx.author}, please check your DMs.") +        except discord.Forbidden: +            await ctx.channel.send(ctx.author.mention, embed=embed) + +        # wait a little bit so those who don't get DMs see the response in-channel before losing perms to see it. +        await asyncio.sleep(3) +        await ctx.author.add_roles(discord.Object(Roles.voice_verified), reason="Voice Gate passed") + +        self.bot.stats.incr("voice_gate.passed") + +    @Cog.listener() +    async def on_message(self, message: discord.Message) -> None: +        """Delete all non-staff messages from voice gate channel that don't invoke voice verify command.""" +        # Check is channel voice gate +        if message.channel.id != Channels.voice_gate: +            return + +        ctx = await self.bot.get_context(message) +        is_verify_command = ctx.command is not None and ctx.command.name == "voice_verify" + +        # When it's bot sent message, delete it after some time +        if message.author.bot: +            with suppress(discord.NotFound): +                await message.delete(delay=GateConf.bot_message_delete_delay) +                return + +        # Then check is member moderator+, because we don't want to delete their messages. +        if any(role.id in MODERATION_ROLES for role in message.author.roles) and is_verify_command is False: +            log.trace(f"Excluding moderator message {message.id} from deletion in #{message.channel}.") +            return + +        # Ignore deleted voice verification messages +        if ctx.command is not None and ctx.command.name == "voice_verify": +            self.mod_log.ignore(Event.message_delete, message.id) + +        with suppress(discord.NotFound): +            await message.delete() + +    async def cog_command_error(self, ctx: Context, error: Exception) -> None: +        """Check for & ignore any InWhitelistCheckFailure.""" +        if isinstance(error, InWhitelistCheckFailure): +            error.handled = True + + +def setup(bot: Bot) -> None: +    """Loads the VoiceGate cog.""" +    bot.add_cog(VoiceGate(bot)) diff --git a/bot/exts/moderation/watchchannels/bigbrother.py b/bot/exts/moderation/watchchannels/bigbrother.py index d7127b5c4..3b44056d3 100644 --- a/bot/exts/moderation/watchchannels/bigbrother.py +++ b/bot/exts/moderation/watchchannels/bigbrother.py @@ -2,12 +2,11 @@ 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.constants import Channels, MODERATION_ROLES, Webhooks  from bot.converters import FetchedMember -from bot.decorators import with_role  from bot.exts.moderation.infraction._utils import post_infraction  from bot.exts.moderation.watchchannels._watchchannel import WatchChannel @@ -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) diff --git a/bot/exts/moderation/watchchannels/talentpool.py b/bot/exts/moderation/watchchannels/talentpool.py index 3724e94e6..a77dbe156 100644 --- a/bot/exts/moderation/watchchannels/talentpool.py +++ b/bot/exts/moderation/watchchannels/talentpool.py @@ -4,13 +4,12 @@ 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 @@ -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. diff --git a/bot/exts/utils/bot.py b/bot/exts/utils/bot.py index 66f340a99..69d623581 100644 --- a/bot/exts/utils/bot.py +++ b/bot/exts/utils/bot.py @@ -1,23 +1,14 @@ -import ast  import logging -import re -import time -from typing import Optional, Tuple +from typing import Optional -from discord import Embed, Message, RawMessageUpdateEvent, TextChannel -from discord.ext.commands import Cog, Context, command, group +from discord import Embed, TextChannel +from discord.ext.commands import Cog, Context, command, group, has_any_role  from bot.bot import Bot -from bot.constants import Categories, Channels, DEBUG_MODE, Guild, MODERATION_ROLES, Roles, URLs -from bot.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 +from bot.constants import Guild, MODERATION_ROLES, Roles, URLs  log = logging.getLogger(__name__) -RE_MARKDOWN = re.compile(r'([*_~`|>])') -  class BotCog(Cog, name="Bot"):      """Bot information commands.""" @@ -25,27 +16,14 @@ class BotCog(Cog, name="Bot"):      def __init__(self, bot: Bot):          self.bot = bot -        # Stores allowed channels plus epoch time since last call. -        self.channel_cooldowns = { -            Channels.python_discussion: 0, -        } - -        # These channels will also work, but will not be subject to cooldown -        self.channel_whitelist = ( -            Channels.bot_commands, -        ) - -        # Stores improperly formatted Python codeblock message ids and the corresponding bot message -        self.codeblock_message_ids = {} -      @group(invoke_without_command=True, name="bot", hidden=True) -    @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 +41,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 +50,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) @@ -82,305 +60,6 @@ class BotCog(Cog, name="Bot"):          else:              await channel.send(embed=embed) -    def codeblock_stripping(self, msg: str, bad_ticks: bool) -> Optional[Tuple[Tuple[str, ...], str]]: -        """ -        Strip msg in order to find Python code. - -        Tries to strip out Python code out of msg and returns the stripped block or -        None if the block is a valid Python codeblock. -        """ -        if msg.count("\n") >= 3: -            # Filtering valid Python codeblocks and exiting if a valid Python codeblock is found. -            if re.search("```(?:py|python)\n(.*?)```", msg, re.IGNORECASE | re.DOTALL) and not bad_ticks: -                log.trace( -                    "Someone wrote a message that was already a " -                    "valid Python syntax highlighted code block. No action taken." -                ) -                return None - -            else: -                # Stripping backticks from every line of the message. -                log.trace(f"Stripping backticks from message.\n\n{msg}\n\n") -                content = "" -                for line in msg.splitlines(keepends=True): -                    content += line.strip("`") - -                content = content.strip() - -                # Remove "Python" or "Py" from start of the message if it exists. -                log.trace(f"Removing 'py' or 'python' from message.\n\n{content}\n\n") -                pycode = False -                if content.lower().startswith("python"): -                    content = content[6:] -                    pycode = True -                elif content.lower().startswith("py"): -                    content = content[2:] -                    pycode = True - -                if pycode: -                    content = content.splitlines(keepends=True) - -                    # Check if there might be code in the first line, and preserve it. -                    first_line = content[0] -                    if " " in content[0]: -                        first_space = first_line.index(" ") -                        content[0] = first_line[first_space:] -                        content = "".join(content) - -                    # If there's no code we can just get rid of the first line. -                    else: -                        content = "".join(content[1:]) - -                # Strip it again to remove any leading whitespace. This is neccessary -                # if the first line of the message looked like ```python <code> -                old = content.strip() - -                # Strips REPL code out of the message if there is any. -                content, repl_code = self.repl_stripping(old) -                if old != content: -                    return (content, old), repl_code - -                # Try to apply indentation fixes to the code. -                content = self.fix_indentation(content) - -                # Check if the code contains backticks, if it does ignore the message. -                if "`" in content: -                    log.trace("Detected ` inside the code, won't reply") -                    return None -                else: -                    log.trace(f"Returning message.\n\n{content}\n\n") -                    return (content,), repl_code - -    def fix_indentation(self, msg: str) -> str: -        """Attempts to fix badly indented code.""" -        def unindent(code: str, skip_spaces: int = 0) -> str: -            """Unindents all code down to the number of spaces given in skip_spaces.""" -            final = "" -            current = code[0] -            leading_spaces = 0 - -            # Get numbers of spaces before code in the first line. -            while current == " ": -                current = code[leading_spaces + 1] -                leading_spaces += 1 -            leading_spaces -= skip_spaces - -            # If there are any, remove that number of spaces from every line. -            if leading_spaces > 0: -                for line in code.splitlines(keepends=True): -                    line = line[leading_spaces:] -                    final += line -                return final -            else: -                return code - -        # Apply fix for "all lines are overindented" case. -        msg = unindent(msg) - -        # If the first line does not end with a colon, we can be -        # certain the next line will be on the same indentation level. -        # -        # If it does end with a colon, we will need to indent all successive -        # lines one additional level. -        first_line = msg.splitlines()[0] -        code = "".join(msg.splitlines(keepends=True)[1:]) -        if not first_line.endswith(":"): -            msg = f"{first_line}\n{unindent(code)}" -        else: -            msg = f"{first_line}\n{unindent(code, 4)}" -        return msg - -    def repl_stripping(self, msg: str) -> Tuple[str, bool]: -        """ -        Strip msg in order to extract Python code out of REPL output. - -        Tries to strip out REPL Python code out of msg and returns the stripped msg. - -        Returns True for the boolean if REPL code was found in the input msg. -        """ -        final = "" -        for line in msg.splitlines(keepends=True): -            if line.startswith(">>>") or line.startswith("..."): -                final += line[4:] -        log.trace(f"Formatted: \n\n{msg}\n\n to \n\n{final}\n\n") -        if not final: -            log.trace(f"Found no REPL code in \n\n{msg}\n\n") -            return msg, False -        else: -            log.trace(f"Found REPL code in \n\n{msg}\n\n") -            return final.rstrip(), True - -    def has_bad_ticks(self, msg: Message) -> bool: -        """Check to see if msg contains ticks that aren't '`'.""" -        not_backticks = [ -            "'''", '"""', "\u00b4\u00b4\u00b4", "\u2018\u2018\u2018", "\u2019\u2019\u2019", -            "\u2032\u2032\u2032", "\u201c\u201c\u201c", "\u201d\u201d\u201d", "\u2033\u2033\u2033", -            "\u3003\u3003\u3003" -        ] - -        return msg.content[:3] in not_backticks - -    @Cog.listener() -    async def on_message(self, msg: Message) -> None: -        """ -        Detect poorly formatted Python code in new messages. - -        If poorly formatted code is detected, send the user a helpful message explaining how to do -        properly formatted Python syntax highlighting codeblocks. -        """ -        is_help_channel = ( -            getattr(msg.channel, "category", None) -            and msg.channel.category.id in (Categories.help_available, Categories.help_in_use) -        ) -        parse_codeblock = ( -            ( -                is_help_channel -                or msg.channel.id in self.channel_cooldowns -                or msg.channel.id in self.channel_whitelist -            ) -            and not msg.author.bot -            and len(msg.content.splitlines()) > 3 -            and not TokenRemover.find_token_in_message(msg) -            and not WEBHOOK_URL_RE.search(msg.content) -        ) - -        if parse_codeblock:  # no token in the msg -            on_cooldown = (time.time() - self.channel_cooldowns.get(msg.channel.id, 0)) < 300 -            if not on_cooldown or DEBUG_MODE: -                try: -                    if self.has_bad_ticks(msg): -                        ticks = msg.content[:3] -                        content = self.codeblock_stripping(f"```{msg.content[3:-3]}```", True) -                        if content is None: -                            return - -                        content, repl_code = content - -                        if len(content) == 2: -                            content = content[1] -                        else: -                            content = content[0] - -                        space_left = 204 -                        if len(content) >= space_left: -                            current_length = 0 -                            lines_walked = 0 -                            for line in content.splitlines(keepends=True): -                                if current_length + len(line) > space_left or lines_walked == 10: -                                    break -                                current_length += len(line) -                                lines_walked += 1 -                            content = content[:current_length] + "#..." -                        content_escaped_markdown = RE_MARKDOWN.sub(r'\\\1', content) -                        howto = ( -                            "It looks like you are trying to paste code into this channel.\n\n" -                            "You seem to be using the wrong symbols to indicate where the codeblock should start. " -                            f"The correct symbols would be \\`\\`\\`, not `{ticks}`.\n\n" -                            "**Here is an example of how it should look:**\n" -                            f"\\`\\`\\`python\n{content_escaped_markdown}\n\\`\\`\\`\n\n" -                            "**This will result in the following:**\n" -                            f"```python\n{content}\n```" -                        ) - -                    else: -                        howto = "" -                        content = self.codeblock_stripping(msg.content, False) -                        if content is None: -                            return - -                        content, repl_code = content -                        # Attempts to parse the message into an AST node. -                        # Invalid Python code will raise a SyntaxError. -                        tree = ast.parse(content[0]) - -                        # Multiple lines of single words could be interpreted as expressions. -                        # This check is to avoid all nodes being parsed as expressions. -                        # (e.g. words over multiple lines) -                        if not all(isinstance(node, ast.Expr) for node in tree.body) or repl_code: -                            # Shorten the code to 10 lines and/or 204 characters. -                            space_left = 204 -                            if content and repl_code: -                                content = content[1] -                            else: -                                content = content[0] - -                            if len(content) >= space_left: -                                current_length = 0 -                                lines_walked = 0 -                                for line in content.splitlines(keepends=True): -                                    if current_length + len(line) > space_left or lines_walked == 10: -                                        break -                                    current_length += len(line) -                                    lines_walked += 1 -                                content = content[:current_length] + "#..." - -                            content_escaped_markdown = RE_MARKDOWN.sub(r'\\\1', content) -                            howto += ( -                                "It looks like you're trying to paste code into this channel.\n\n" -                                "Discord has support for Markdown, which allows you to post code with full " -                                "syntax highlighting. Please use these whenever you paste code, as this " -                                "helps improve the legibility and makes it easier for us to help you.\n\n" -                                f"**To do this, use the following method:**\n" -                                f"\\`\\`\\`python\n{content_escaped_markdown}\n\\`\\`\\`\n\n" -                                "**This will result in the following:**\n" -                                f"```python\n{content}\n```" -                            ) - -                            log.debug(f"{msg.author} posted something that needed to be put inside python code " -                                      "blocks. Sending the user some instructions.") -                        else: -                            log.trace("The code consists only of expressions, not sending instructions") - -                    if howto != "": -                        # Increase amount of codeblock correction in stats -                        self.bot.stats.incr("codeblock_corrections") -                        howto_embed = Embed(description=howto) -                        bot_message = await msg.channel.send(f"Hey {msg.author.mention}!", embed=howto_embed) -                        self.codeblock_message_ids[msg.id] = bot_message.id - -                        self.bot.loop.create_task( -                            wait_for_deletion(bot_message, (msg.author.id,), self.bot) -                        ) -                    else: -                        return - -                    if msg.channel.id not in self.channel_whitelist: -                        self.channel_cooldowns[msg.channel.id] = time.time() - -                except SyntaxError: -                    log.trace( -                        f"{msg.author} posted in a help channel, and when we tried to parse it as Python code, " -                        "ast.parse raised a SyntaxError. This probably just means it wasn't Python code. " -                        f"The message that was posted was:\n\n{msg.content}\n\n" -                    ) - -    @Cog.listener() -    async def on_raw_message_edit(self, payload: RawMessageUpdateEvent) -> None: -        """Check to see if an edited message (previously called out) still contains poorly formatted code.""" -        if ( -            # Checks to see if the message was called out by the bot -            payload.message_id not in self.codeblock_message_ids -            # Makes sure that there is content in the message -            or payload.data.get("content") is None -            # Makes sure there's a channel id in the message payload -            or payload.data.get("channel_id") is None -        ): -            return - -        # Retrieve channel and message objects for use later -        channel = self.bot.get_channel(int(payload.data.get("channel_id"))) -        user_message = await channel.fetch_message(payload.message_id) - -        #  Checks to see if the user has corrected their codeblock.  If it's fixed, has_fixed_codeblock will be None -        has_fixed_codeblock = self.codeblock_stripping(payload.data.get("content"), self.has_bad_ticks(user_message)) - -        # If the message is fixed, delete the bot message and the entry from the id dictionary -        if has_fixed_codeblock is None: -            bot_message = await channel.fetch_message(self.codeblock_message_ids[payload.message_id]) -            await bot_message.delete() -            del self.codeblock_message_ids[payload.message_id] -            log.trace("User's incorrect code block has been fixed. Removing bot formatting message.") -  def setup(bot: Bot) -> None:      """Load the Bot cog.""" diff --git a/bot/exts/utils/clean.py b/bot/exts/utils/clean.py index d9a7aafe1..bf25cb4c2 100644 --- a/bot/exts/utils/clean.py +++ b/bot/exts/utils/clean.py @@ -5,13 +5,12 @@ 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.constants import (      Channels, CleanMessages, Colours, Event, Icons, MODERATION_ROLES, NEGATIVE_REPLIES  ) -from bot.decorators import with_role  from bot.exts.moderation.modlog import ModLog  log = logging.getLogger(__name__) @@ -179,7 +178,8 @@ class Clean(Cog):          target_channels = ", ".join(channel.mention for channel in channels)          message = ( -            f"**{len(message_ids)}** messages deleted in {target_channels} by **{ctx.author.name}**\n\n" +            f"**{len(message_ids)}** messages deleted in {target_channels} by " +            f"{ctx.author.mention}\n\n"              f"A log of the deleted messages can be found [here]({log_url})."          ) @@ -192,13 +192,13 @@ class Clean(Cog):          )      @group(invoke_without_command=True, name="clean", aliases=["purge"]) -    @with_role(*MODERATION_ROLES) +    @has_any_role(*MODERATION_ROLES)      async def clean_group(self, ctx: Context) -> None:          """Commands for cleaning messages in channels."""          await ctx.send_help(ctx.command)      @clean_group.command(name="user", aliases=["users"]) -    @with_role(*MODERATION_ROLES) +    @has_any_role(*MODERATION_ROLES)      async def clean_user(          self,          ctx: Context, @@ -210,7 +210,7 @@ class Clean(Cog):          await self._clean_messages(amount, ctx, user=user, channels=channels)      @clean_group.command(name="all", aliases=["everything"]) -    @with_role(*MODERATION_ROLES) +    @has_any_role(*MODERATION_ROLES)      async def clean_all(          self,          ctx: Context, @@ -221,7 +221,7 @@ class Clean(Cog):          await self._clean_messages(amount, ctx, channels=channels)      @clean_group.command(name="bots", aliases=["bot"]) -    @with_role(*MODERATION_ROLES) +    @has_any_role(*MODERATION_ROLES)      async def clean_bots(          self,          ctx: Context, @@ -232,7 +232,7 @@ class Clean(Cog):          await self._clean_messages(amount, ctx, bots_only=True, channels=channels)      @clean_group.command(name="regex", aliases=["word", "expression"]) -    @with_role(*MODERATION_ROLES) +    @has_any_role(*MODERATION_ROLES)      async def clean_regex(          self,          ctx: Context, @@ -244,7 +244,7 @@ class Clean(Cog):          await self._clean_messages(amount, ctx, regex=regex, channels=channels)      @clean_group.command(name="message", aliases=["messages"]) -    @with_role(*MODERATION_ROLES) +    @has_any_role(*MODERATION_ROLES)      async def clean_message(self, ctx: Context, message: Message) -> None:          """Delete all messages until certain message, stop cleaning after hitting the `message`."""          await self._clean_messages( @@ -255,7 +255,7 @@ class Clean(Cog):          )      @clean_group.command(name="stop", aliases=["cancel", "abort"]) -    @with_role(*MODERATION_ROLES) +    @has_any_role(*MODERATION_ROLES)      async def clean_cancel(self, ctx: Context) -> None:          """If there is an ongoing cleaning process, attempt to immediately cancel it."""          self.cleaning = False diff --git a/bot/exts/utils/extensions.py b/bot/exts/utils/extensions.py index 123f356e8..418db0150 100644 --- a/bot/exts/utils/extensions.py +++ b/bot/exts/utils/extensions.py @@ -11,7 +11,6 @@ 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__) @@ -248,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/exts/utils/eval.py b/bot/exts/utils/internal.py index 23e5998d8..1b4900f42 100644 --- a/bot/exts/utils/eval.py +++ b/bot/exts/utils/internal.py @@ -5,23 +5,24 @@ import pprint  import re  import textwrap  import traceback +from collections import Counter +from datetime import datetime  from io import StringIO  from typing import Any, Optional, Tuple  import discord -from discord.ext.commands import Cog, Context, group +from discord.ext.commands import Cog, Context, group, has_any_role  from bot.bot import Bot  from bot.constants import Roles -from bot.decorators import with_role  from bot.interpreter import Interpreter  from bot.utils import find_nth_occurrence, send_to_paste_service  log = logging.getLogger(__name__) -class CodeEval(Cog): -    """Owner and admin feature that evaluates code and returns the result to the channel.""" +class Internal(Cog): +    """Administrator and Core Developer commands."""      def __init__(self, bot: Bot):          self.bot = bot @@ -31,6 +32,17 @@ class CodeEval(Cog):          self.interpreter = Interpreter(bot) +        self.socket_since = datetime.utcnow() +        self.socket_event_total = 0 +        self.socket_events = Counter() + +    @Cog.listener() +    async def on_socket_response(self, msg: dict) -> None: +        """When a websocket event is received, increase our counters.""" +        if event_type := msg.get("t"): +            self.socket_event_total += 1 +            self.socket_events[event_type] += 1 +      def _format(self, inp: str, out: Any) -> Tuple[str, Optional[discord.Embed]]:          """Format the eval output into a string & attempt to format it into an Embed."""          self._ = out @@ -199,14 +211,14 @@ async def func():  # (None,) -> Any          await ctx.send(f"```py\n{out}```", embed=embed)      @group(name='internal', aliases=('int',)) -    @with_role(Roles.owners, Roles.admins) +    @has_any_role(Roles.owners, Roles.admins, Roles.core_developers)      async def internal_group(self, ctx: Context) -> None:          """Internal commands. Top secret!"""          if not ctx.invoked_subcommand:              await ctx.send_help(ctx.command)      @internal_group.command(name='eval', aliases=('e',)) -    @with_role(Roles.admins, Roles.owners) +    @has_any_role(Roles.admins, Roles.owners)      async def eval(self, ctx: Context, *, code: str) -> None:          """Run eval in a REPL-like format."""          code = code.strip("`") @@ -221,7 +233,26 @@ async def func():  # (None,) -> Any          await self._eval(ctx, code) +    @internal_group.command(name='socketstats', aliases=('socket', 'stats')) +    @has_any_role(Roles.admins, Roles.owners, Roles.core_developers) +    async def socketstats(self, ctx: Context) -> None: +        """Fetch information on the socket events received from Discord.""" +        running_s = (datetime.utcnow() - self.socket_since).total_seconds() + +        per_s = self.socket_event_total / running_s + +        stats_embed = discord.Embed( +            title="WebSocket statistics", +            description=f"Receiving {per_s:0.2f} event per second.", +            color=discord.Color.blurple() +        ) + +        for event_type, count in self.socket_events.most_common(25): +            stats_embed.add_field(name=event_type, value=count, inline=False) + +        await ctx.send(embed=stats_embed) +  def setup(bot: Bot) -> None: -    """Load the CodeEval cog.""" -    bot.add_cog(CodeEval(bot)) +    """Load the Internal cog.""" +    bot.add_cog(Internal(bot)) diff --git a/bot/exts/utils/jams.py b/bot/exts/utils/jams.py index b3102db2f..1c0988343 100644 --- a/bot/exts/utils/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..572fc934b --- /dev/null +++ b/bot/exts/utils/ping.py @@ -0,0 +1,59 @@ +import socket +from datetime import datetime + +import aioping +from discord import Embed +from discord.ext import commands + +from bot.bot import Bot +from bot.constants import Channels, Emojis, STAFF_ROLES, URLs +from bot.decorators import in_whitelist + +DESCRIPTIONS = ( +    "Command processing time", +    "Python Discord website latency", +    "Discord API latency" +) +ROUND_LATENCY = 3 + + +class Latency(commands.Cog): +    """Getting the latency between the bot and websites.""" + +    def __init__(self, bot: Bot) -> None: +        self.bot = bot + +    @commands.command() +    @in_whitelist(channels=(Channels.bot_commands,), roles=STAFF_ROLES) +    async def ping(self, ctx: commands.Context) -> None: +        """ +        Gets different measures of latency within the bot. + +        Returns bot, Python Discord Site, Discord Protocol latency. +        """ +        # datetime.datetime objects do not have the "milliseconds" attribute. +        # It must be converted to seconds before converting to milliseconds. +        bot_ping = (datetime.utcnow() - ctx.message.created_at).total_seconds() * 1000 +        bot_ping = f"{bot_ping:.{ROUND_LATENCY}f} ms" + +        try: +            delay = await aioping.ping(URLs.site, family=socket.AddressFamily.AF_INET) * 1000 +            site_ping = f"{delay:.{ROUND_LATENCY}f} ms" + +        except TimeoutError: +            site_ping = f"{Emojis.cross_mark} Connection timed out." + +        # Discord Protocol latency return value is in seconds, must be multiplied by 1000 to get milliseconds. +        discord_ping = f"{self.bot.latency * 1000:.{ROUND_LATENCY}f} ms" + +        embed = Embed(title="Pong!") + +        for desc, latency in zip(DESCRIPTIONS, [bot_ping, site_ping, discord_ping]): +            embed.add_field(name=desc, value=latency, inline=False) + +        await ctx.send(embed=embed) + + +def setup(bot: Bot) -> None: +    """Load the Latency cog.""" +    bot.add_cog(Latency(bot)) diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py index 08bce2153..3113a1149 100644 --- a/bot/exts/utils/reminders.py +++ b/bot/exts/utils/reminders.py @@ -15,13 +15,15 @@ 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.lock import lock_arg  from bot.utils.messages import send_denial  from bot.utils.scheduling import Scheduler  from bot.utils.time import humanize_delta  log = logging.getLogger(__name__) +LOCK_NAMESPACE = "reminder"  WHITELISTED_CHANNELS = Guild.reminder_whitelist  MAXIMUM_REMINDERS = 5 @@ -52,7 +54,7 @@ class Reminders(Cog):          now = datetime.utcnow()          for reminder in response: -            is_valid, *_ = self.ensure_valid_reminder(reminder, cancel_task=False) +            is_valid, *_ = self.ensure_valid_reminder(reminder)              if not is_valid:                  continue @@ -65,11 +67,7 @@ class Reminders(Cog):              else:                  self.schedule_reminder(reminder) -    def ensure_valid_reminder( -        self, -        reminder: dict, -        cancel_task: bool = True -    ) -> t.Tuple[bool, discord.User, discord.TextChannel]: +    def ensure_valid_reminder(self, reminder: dict) -> t.Tuple[bool, discord.User, discord.TextChannel]:          """Ensure reminder author and channel can be fetched otherwise delete the reminder."""          user = self.bot.get_user(reminder['author'])          channel = self.bot.get_channel(reminder['channel_id']) @@ -80,7 +78,7 @@ class Reminders(Cog):                  f"Reminder {reminder['id']} invalid: "                  f"User {reminder['author']}={user}, Channel {reminder['channel_id']}={channel}."              ) -            asyncio.create_task(self._delete_reminder(reminder['id'], cancel_task)) +            asyncio.create_task(self.bot.api_client.delete(f"bot/reminders/{reminder['id']}"))          return is_valid, user, channel @@ -88,7 +86,7 @@ class Reminders(Cog):      async def _send_confirmation(          ctx: Context,          on_success: str, -        reminder_id: str, +        reminder_id: t.Union[str, int],          delivery_dt: t.Optional[datetime],      ) -> None:          """Send an embed confirming the reminder change was made successfully.""" @@ -117,9 +115,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, "" @@ -148,24 +146,8 @@ class Reminders(Cog):      def schedule_reminder(self, reminder: dict) -> None:          """A coroutine which sends the reminder once the time is reached, and cancels the running task.""" -        reminder_id = reminder["id"]          reminder_datetime = isoparse(reminder['expiration']).replace(tzinfo=None) - -        async def _remind() -> None: -            await self.send_reminder(reminder) - -            log.debug(f"Deleting reminder {reminder_id} (the user has been reminded).") -            await self._delete_reminder(reminder_id) - -        self.scheduler.schedule_at(reminder_datetime, reminder_id, _remind()) - -    async def _delete_reminder(self, reminder_id: str, cancel_task: bool = True) -> None: -        """Delete a reminder from the database, given its ID, and cancel the running task.""" -        await self.bot.api_client.delete('bot/reminders/' + str(reminder_id)) - -        if cancel_task: -            # Now we can remove it from the schedule list -            self.scheduler.cancel(reminder_id) +        self.scheduler.schedule_at(reminder_datetime, reminder["id"], self.send_reminder(reminder))      async def _edit_reminder(self, reminder_id: int, payload: dict) -> dict:          """ @@ -188,10 +170,12 @@ class Reminders(Cog):          log.trace(f"Scheduling new task #{reminder['id']}")          self.schedule_reminder(reminder) +    @lock_arg(LOCK_NAMESPACE, "reminder", itemgetter("id"), raise_error=True)      async def send_reminder(self, reminder: dict, late: relativedelta = None) -> None:          """Send the reminder."""          is_valid, user, channel = self.ensure_valid_reminder(reminder)          if not is_valid: +            # No need to cancel the task too; it'll simply be done once this coroutine returns.              return          embed = discord.Embed() @@ -217,18 +201,17 @@ class Reminders(Cog):              mentionable.mention for mentionable in self.get_mentionables(reminder["mentions"])          ) -        await channel.send( -            content=f"{user.mention} {additional_mentions}", -            embed=embed -        ) -        await self._delete_reminder(reminder["id"]) +        await channel.send(content=f"{user.mention} {additional_mentions}", embed=embed) + +        log.debug(f"Deleting reminder #{reminder['id']} (the user has been reminded).") +        await self.bot.api_client.delete(f"bot/reminders/{reminder['id']}")      @group(name="remind", aliases=("reminder", "reminders", "remindme"), invoke_without_command=True)      async def remind_group(          self, ctx: Context, mentions: Greedy[Mentionable], expiration: Duration, *, content: str      ) -> None:          """Commands for managing your reminders.""" -        await ctx.invoke(self.new_reminder, mentions=mentions, expiration=expiration, content=content) +        await self.new_reminder(ctx, mentions=mentions, expiration=expiration, content=content)      @remind_group.command(name="new", aliases=("add", "create"))      async def new_reminder( @@ -240,7 +223,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: @@ -286,10 +269,11 @@ class Reminders(Cog):          now = datetime.utcnow() - timedelta(seconds=1)          humanized_delta = humanize_delta(relativedelta(expiration, now)) -        mention_string = ( -            f"Your reminder will arrive in {humanized_delta} " -            f"and will mention {len(mentions)} other(s)!" -        ) +        mention_string = f"Your reminder will arrive in {humanized_delta}" + +        if mentions: +            mention_string += f" and will mention {len(mentions)} other(s)" +        mention_string += "!"          # Confirm to the user that it worked.          await self._send_confirmation( @@ -394,6 +378,7 @@ class Reminders(Cog):          mention_ids = [mention.id for mention in mentions]          await self.edit_reminder(ctx, id_, {"mentions": mention_ids}) +    @lock_arg(LOCK_NAMESPACE, "id_", raise_error=True)      async def edit_reminder(self, ctx: Context, id_: int, payload: dict) -> None:          """Edits a reminder with the given payload, then sends a confirmation message."""          if not await self._can_modify(ctx, id_): @@ -413,11 +398,15 @@ class Reminders(Cog):          await self._reschedule_reminder(reminder)      @remind_group.command("delete", aliases=("remove", "cancel")) +    @lock_arg(LOCK_NAMESPACE, "id_", raise_error=True)      async def delete_reminder(self, ctx: Context, id_: int) -> None:          """Delete one of your active reminders."""          if not await self._can_modify(ctx, id_):              return -        await self._delete_reminder(id_) + +        await self.bot.api_client.delete(f"bot/reminders/{id_}") +        self.scheduler.cancel(id_) +          await self._send_confirmation(              ctx,              on_success="That reminder has been deleted successfully!", @@ -431,7 +420,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/exts/utils/snekbox.py b/bot/exts/utils/snekbox.py index 03bf454ac..cad451571 100644 --- a/bot/exts/utils/snekbox.py +++ b/bot/exts/utils/snekbox.py @@ -38,10 +38,10 @@ RAW_CODE_REGEX = re.compile(      re.DOTALL                               # "." also matches newlines  ) -MAX_PASTE_LEN = 1000 +MAX_PASTE_LEN = 10000  # `!eval` command whitelists -EVAL_CHANNELS = (Channels.bot_commands, Channels.esoteric) +EVAL_CHANNELS = (Channels.bot_commands, Channels.esoteric, Channels.code_help_voice, Channels.code_help_voice_2)  EVAL_CATEGORIES = (Categories.help_available, Categories.help_in_use)  EVAL_ROLES = (Roles.helpers, Roles.moderators, Roles.admins, Roles.owners, Roles.python_community, Roles.partners) @@ -150,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 @@ -240,12 +241,12 @@ class Snekbox(Cog):                  )                  code = await self.get_code(new_message) -                await ctx.message.clear_reactions() +                await ctx.message.clear_reaction(REEVAL_EMOJI)                  with contextlib.suppress(HTTPException):                      await response.delete()              except asyncio.TimeoutError: -                await ctx.message.clear_reactions() +                await ctx.message.clear_reaction(REEVAL_EMOJI)                  return None              return code diff --git a/bot/exts/utils/utils.py b/bot/exts/utils/utils.py index d96abbd5a..3e9230414 100644 --- a/bot/exts/utils/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 @@ -84,7 +84,7 @@ class Utils(Cog):                  # Assemble the embed                  pep_embed = Embed(                      title=f"**PEP {pep_number} - {pep_header['Title']}**", -                    description=f"[Link]({self.base_pep_url}{pep_number:04})", +                    url=f"{self.base_pep_url}{pep_number:04}"                  )                  pep_embed.set_thumbnail(url=ICON_URL) @@ -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. @@ -250,7 +250,7 @@ class Utils(Cog):          """Send information about PEP 0."""          pep_embed = Embed(              title="**PEP 0 - Index of Python Enhancement Proposals (PEPs)**", -            description="[Link](https://www.python.org/dev/peps/)" +            url="https://www.python.org/dev/peps/"          )          pep_embed.set_thumbnail(url=ICON_URL)          pep_embed.add_field(name="Status", value="Active") diff --git a/bot/patches/__init__.py b/bot/patches/__init__.py deleted file mode 100644 index 60f6becaa..000000000 --- a/bot/patches/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Subpackage that contains patches for discord.py.""" -from . import message_edited_at - -__all__ = [ -    message_edited_at, -] diff --git a/bot/patches/message_edited_at.py b/bot/patches/message_edited_at.py deleted file mode 100644 index a0154f12d..000000000 --- a/bot/patches/message_edited_at.py +++ /dev/null @@ -1,32 +0,0 @@ -""" -# message_edited_at patch. - -Date: 2019-09-16 -Author: Scragly -Added by: Ves Zappa - -Due to a bug in our current version of discord.py (1.2.3), the edited_at timestamp of -`discord.Messages` are not being handled correctly. This patch fixes that until a new -release of discord.py is released (and we've updated to it). -""" -import logging - -from discord import message, utils - -log = logging.getLogger(__name__) - - -def _handle_edited_timestamp(self: message.Message, value: str) -> None: -    """Helper function that takes care of parsing the edited timestamp.""" -    self._edited_timestamp = utils.parse_time(value) - - -def apply_patch() -> None: -    """Applies the `edited_at` patch to the `discord.message.Message` class.""" -    message.Message._handle_edited_timestamp = _handle_edited_timestamp -    message.Message._HANDLERS['edited_timestamp'] = message.Message._handle_edited_timestamp -    log.info("Patch applied: message_edited_at") - - -if __name__ == "__main__": -    apply_patch() diff --git a/bot/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/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 3e93fcb06..13533a467 100644 --- a/bot/utils/__init__.py +++ b/bot/utils/__init__.py @@ -1,5 +1,4 @@ -from bot.utils.helpers import CogABCMeta, find_nth_occurrence, pad_base64 -from bot.utils.redis_cache import RedisCache +from bot.utils.helpers import CogABCMeta, find_nth_occurrence, has_lines, pad_base64  from bot.utils.services import send_to_paste_service -__all__ = ['RedisCache', 'CogABCMeta', 'find_nth_occurrence', 'pad_base64', 'send_to_paste_service'] +__all__ = ['CogABCMeta', 'find_nth_occurrence', 'has_lines', 'pad_base64', 'send_to_paste_service'] diff --git a/bot/utils/channel.py b/bot/utils/channel.py new file mode 100644 index 000000000..6bf70bfde --- /dev/null +++ b/bot/utils/channel.py @@ -0,0 +1,49 @@ +import logging + +import discord + +from bot import constants +from bot.constants import Categories + +log = logging.getLogger(__name__) + + +def is_help_channel(channel: discord.TextChannel) -> bool: +    """Return True if `channel` is in one of the help categories (excluding dormant).""" +    log.trace(f"Checking if #{channel} is a help channel.") +    categories = (Categories.help_available, Categories.help_in_use) + +    return any(is_in_category(channel, category) for category in categories) + + +def is_mod_channel(channel: discord.TextChannel) -> bool: +    """True if `channel` is considered a mod channel.""" +    if channel.id in constants.MODERATION_CHANNELS: +        log.trace(f"Channel #{channel} is a configured mod channel") +        return True + +    elif any(is_in_category(channel, category) for category in constants.MODERATION_CATEGORIES): +        log.trace(f"Channel #{channel} is in a configured mod category") +        return True + +    else: +        log.trace(f"Channel #{channel} is not a mod channel") +        return False + + +def is_in_category(channel: discord.TextChannel, category_id: int) -> bool: +    """Return True if `channel` is within a category with `category_id`.""" +    return getattr(channel, "category_id", None) == category_id + + +async def try_get_channel(channel_id: int, client: discord.Client) -> discord.abc.GuildChannel: +    """Attempt to get or fetch a channel and return it.""" +    log.trace(f"Getting the channel {channel_id}.") + +    channel = client.get_channel(channel_id) +    if not channel: +        log.debug(f"Channel {channel_id} is not in cache; fetching from API.") +        channel = await client.fetch_channel(channel_id) + +    log.trace(f"Channel #{channel} ({channel_id}) retrieved.") +    return channel diff --git a/bot/utils/checks.py b/bot/utils/checks.py index f0ef36302..460a937d8 100644 --- a/bot/utils/checks.py +++ b/bot/utils/checks.py @@ -1,6 +1,6 @@  import datetime  import logging -from typing import Callable, Container, Iterable, Optional +from typing import Callable, Container, Iterable, Optional, Union  from discord.ext.commands import (      BucketType, @@ -11,6 +11,8 @@ from discord.ext.commands import (      Context,      Cooldown,      CooldownMapping, +    NoPrivateMessage, +    has_any_role,  )  from bot import constants @@ -89,35 +91,32 @@ def in_whitelist_check(      return False -def with_role_check(ctx: Context, *role_ids: int) -> bool: -    """Returns True if the user has any one of the roles in role_ids.""" -    if not ctx.guild:  # Return False in a DM -        log.trace(f"{ctx.author} tried to use the '{ctx.command.name}'command from a DM. " -                  "This command is restricted by the with_role decorator. Rejecting request.") -        return False +async def has_any_role_check(ctx: Context, *roles: Union[str, int]) -> bool: +    """ +    Returns True if the context's author has any of the specified roles. -    for role in ctx.author.roles: -        if role.id in role_ids: -            log.trace(f"{ctx.author} has the '{role.name}' role, and passes the check.") -            return True +    `roles` are the names or IDs of the roles for which to check. +    False is always returns if the context is outside a guild. +    """ +    try: +        return await has_any_role(*roles).predicate(ctx) +    except CheckFailure: +        return False -    log.trace(f"{ctx.author} does not have the required role to use " -              f"the '{ctx.command.name}' command, so the request is rejected.") -    return False +async def has_no_roles_check(ctx: Context, *roles: Union[str, int]) -> bool: +    """ +    Returns True if the context's author doesn't have any of the specified roles. -def without_role_check(ctx: Context, *role_ids: int) -> bool: -    """Returns True if the user does not have any of the roles in role_ids.""" -    if not ctx.guild:  # Return False in a DM -        log.trace(f"{ctx.author} tried to use the '{ctx.command.name}' command from a DM. " -                  "This command is restricted by the without_role decorator. Rejecting request.") +    `roles` are the names or IDs of the roles for which to check. +    False is always returns if the context is outside a guild. +    """ +    try: +        return not await has_any_role(*roles).predicate(ctx) +    except NoPrivateMessage:          return False - -    author_roles = [role.id for role in ctx.author.roles] -    check = all(role not in author_roles for role in role_ids) -    log.trace(f"{ctx.author} tried to call the '{ctx.command.name}' command. " -              f"The result of the without_role check was {check}.") -    return check +    except CheckFailure: +        return True  def cooldown_with_role_bypass(rate: int, per: float, type: BucketType = BucketType.default, *, diff --git a/bot/utils/function.py b/bot/utils/function.py new file mode 100644 index 000000000..3ab32fe3c --- /dev/null +++ b/bot/utils/function.py @@ -0,0 +1,75 @@ +"""Utilities for interaction with functions.""" + +import inspect +import typing as t + +Argument = t.Union[int, str] +BoundArgs = t.OrderedDict[str, t.Any] +Decorator = t.Callable[[t.Callable], t.Callable] +ArgValGetter = t.Callable[[BoundArgs], t.Any] + + +def get_arg_value(name_or_pos: Argument, arguments: BoundArgs) -> t.Any: +    """ +    Return a value from `arguments` based on a name or position. + +    `arguments` is an ordered mapping of parameter names to argument values. + +    Raise TypeError if `name_or_pos` isn't a str or int. +    Raise ValueError if `name_or_pos` does not match any argument. +    """ +    if isinstance(name_or_pos, int): +        # Convert arguments to a tuple to make them indexable. +        arg_values = tuple(arguments.items()) +        arg_pos = name_or_pos + +        try: +            name, value = arg_values[arg_pos] +            return value +        except IndexError: +            raise ValueError(f"Argument position {arg_pos} is out of bounds.") +    elif isinstance(name_or_pos, str): +        arg_name = name_or_pos +        try: +            return arguments[arg_name] +        except KeyError: +            raise ValueError(f"Argument {arg_name!r} doesn't exist.") +    else: +        raise TypeError("'arg' must either be an int (positional index) or a str (keyword).") + + +def get_arg_value_wrapper( +    decorator_func: t.Callable[[ArgValGetter], Decorator], +    name_or_pos: Argument, +    func: t.Callable[[t.Any], t.Any] = None, +) -> Decorator: +    """ +    Call `decorator_func` with the value of the arg at the given name/position. + +    `decorator_func` must accept a callable as a parameter to which it will pass a mapping of +    parameter names to argument values of the function it's decorating. + +    `func` is an optional callable which will return a new value given the argument's value. + +    Return the decorator returned by `decorator_func`. +    """ +    def wrapper(args: BoundArgs) -> t.Any: +        value = get_arg_value(name_or_pos, args) +        if func: +            value = func(value) +        return value + +    return decorator_func(wrapper) + + +def get_bound_args(func: t.Callable, args: t.Tuple, kwargs: t.Dict[str, t.Any]) -> BoundArgs: +    """ +    Bind `args` and `kwargs` to `func` and return a mapping of parameter names to argument values. + +    Default parameter values are also set. +    """ +    sig = inspect.signature(func) +    bound_args = sig.bind(*args, **kwargs) +    bound_args.apply_defaults() + +    return bound_args.arguments diff --git a/bot/utils/helpers.py b/bot/utils/helpers.py index d9b60af07..3501a3933 100644 --- a/bot/utils/helpers.py +++ b/bot/utils/helpers.py @@ -18,6 +18,15 @@ def find_nth_occurrence(string: str, substring: str, n: int) -> Optional[int]:      return index +def has_lines(string: str, count: int) -> bool: +    """Return True if `string` has at least `count` lines.""" +    # Benchmarks show this is significantly faster than using str.count("\n") or a for loop & break. +    split = string.split("\n", count - 1) + +    # Make sure the last part isn't empty, which would happen if there was a final newline. +    return split[-1] and len(split) == count + +  def pad_base64(data: str) -> str:      """Return base64 `data` with padding characters to ensure its length is a multiple of 4."""      return data + "=" * (-len(data) % 4) diff --git a/bot/utils/lock.py b/bot/utils/lock.py new file mode 100644 index 000000000..7aaafbc88 --- /dev/null +++ b/bot/utils/lock.py @@ -0,0 +1,114 @@ +import inspect +import logging +from collections import defaultdict +from functools import partial, wraps +from typing import Any, Awaitable, Callable, Hashable, Union +from weakref import WeakValueDictionary + +from bot.errors import LockedResourceError +from bot.utils import function + +log = logging.getLogger(__name__) +__lock_dicts = defaultdict(WeakValueDictionary) + +_IdCallableReturn = Union[Hashable, Awaitable[Hashable]] +_IdCallable = Callable[[function.BoundArgs], _IdCallableReturn] +ResourceId = Union[Hashable, _IdCallable] + + +class LockGuard: +    """ +    A context manager which acquires and releases a lock (mutex). + +    Raise RuntimeError if trying to acquire a locked lock. +    """ + +    def __init__(self): +        self._locked = False + +    @property +    def locked(self) -> bool: +        """Return True if currently locked or False if unlocked.""" +        return self._locked + +    def __enter__(self): +        if self._locked: +            raise RuntimeError("Cannot acquire a locked lock.") + +        self._locked = True + +    def __exit__(self, _exc_type, _exc_value, _traceback):  # noqa: ANN001 +        self._locked = False +        return False  # Indicate any raised exception shouldn't be suppressed. + + +def lock(namespace: Hashable, resource_id: ResourceId, *, raise_error: bool = False) -> Callable: +    """ +    Turn the decorated coroutine function into a mutually exclusive operation on a `resource_id`. + +    If any other mutually exclusive function currently holds the lock for a resource, do not run the +    decorated function and return None. If `raise_error` is True, raise `LockedResourceError` if +    the lock cannot be acquired. + +    `namespace` is an identifier used to prevent collisions among resource IDs. + +    `resource_id` identifies a resource on which to perform a mutually exclusive operation. +    It may also be a callable or awaitable which will return the resource ID given an ordered +    mapping of the parameters' names to arguments' values. + +    If decorating a command, this decorator must go before (below) the `command` decorator. +    """ +    def decorator(func: Callable) -> Callable: +        name = func.__name__ + +        @wraps(func) +        async def wrapper(*args, **kwargs) -> Any: +            log.trace(f"{name}: mutually exclusive decorator called") + +            if callable(resource_id): +                log.trace(f"{name}: binding args to signature") +                bound_args = function.get_bound_args(func, args, kwargs) + +                log.trace(f"{name}: calling the given callable to get the resource ID") +                id_ = resource_id(bound_args) + +                if inspect.isawaitable(id_): +                    log.trace(f"{name}: awaiting to get resource ID") +                    id_ = await id_ +            else: +                id_ = resource_id + +            log.trace(f"{name}: getting lock for resource {id_!r} under namespace {namespace!r}") + +            # Get the lock for the ID. Create a lock if one doesn't exist yet. +            locks = __lock_dicts[namespace] +            lock_guard = locks.setdefault(id_, LockGuard()) + +            if not lock_guard.locked: +                log.debug(f"{name}: resource {namespace!r}:{id_!r} is free; acquiring it...") +                with lock_guard: +                    return await func(*args, **kwargs) +            else: +                log.info(f"{name}: aborted because resource {namespace!r}:{id_!r} is locked") +                if raise_error: +                    raise LockedResourceError(str(namespace), id_) + +        return wrapper +    return decorator + + +def lock_arg( +    namespace: Hashable, +    name_or_pos: function.Argument, +    func: Callable[[Any], _IdCallableReturn] = None, +    *, +    raise_error: bool = False, +) -> Callable: +    """ +    Apply the `lock` decorator using the value of the arg at the given name/position as the ID. + +    `func` is an optional callable or awaitable which will return the ID given the argument value. +    See `lock` docs for more information. +    """ +    decorator_func = partial(lock, namespace, raise_error=raise_error) +    return function.get_arg_value_wrapper(decorator_func, name_or_pos, func) diff --git a/bot/utils/messages.py b/bot/utils/messages.py index aa8f17f75..b6c7cab50 100644 --- a/bot/utils/messages.py +++ b/bot/utils/messages.py @@ -6,8 +6,7 @@ import re  from io import BytesIO  from typing import List, Optional, Sequence, Union -from discord import Client, Colour, Embed, File, Member, Message, Reaction, TextChannel, Webhook -from discord.abc import Snowflake +import discord  from discord.errors import HTTPException  from discord.ext.commands import Context @@ -17,9 +16,9 @@ log = logging.getLogger(__name__)  async def wait_for_deletion( -    message: Message, -    user_ids: Sequence[Snowflake], -    client: Client, +    message: discord.Message, +    user_ids: Sequence[discord.abc.Snowflake], +    client: discord.Client,      deletion_emojis: Sequence[str] = (Emojis.trashcan,),      timeout: float = 60 * 5,      attach_emojis: bool = True, @@ -35,9 +34,13 @@ async def wait_for_deletion(      if attach_emojis:          for emoji in deletion_emojis: -            await message.add_reaction(emoji) +            try: +                await message.add_reaction(emoji) +            except discord.NotFound: +                log.trace(f"Aborting wait_for_deletion: message {message.id} deleted prematurely.") +                return -    def check(reaction: Reaction, user: Member) -> bool: +    def check(reaction: discord.Reaction, user: discord.Member) -> bool:          """Check that the deletion emoji is reacted by the appropriate user."""          return (              reaction.message.id == message.id @@ -51,17 +54,26 @@ async def wait_for_deletion(  async def send_attachments( -    message: Message, -    destination: Union[TextChannel, Webhook], -    link_large: bool = True +    message: discord.Message, +    destination: Union[discord.TextChannel, discord.Webhook], +    link_large: bool = True, +    use_cached: bool = False, +    **kwargs  ) -> List[str]:      """      Re-upload the message's attachments to the destination and return a list of their new URLs.      Each attachment is sent as a separate message to more easily comply with the request/file size      limit. If link_large is True, attachments which are too large are instead grouped into a single -    embed which links to them. +    embed which links to them. Extra kwargs will be passed to send() when sending the attachment.      """ +    webhook_send_kwargs = { +        'username': message.author.display_name, +        'avatar_url': message.author.avatar_url, +    } +    webhook_send_kwargs.update(kwargs) +    webhook_send_kwargs['username'] = sub_clyde(webhook_send_kwargs['username']) +      large = []      urls = []      for attachment in message.attachments: @@ -75,18 +87,14 @@ async def send_attachments(              # but some may get through hence the try-catch.              if attachment.size <= destination.guild.filesize_limit - 512:                  with BytesIO() as file: -                    await attachment.save(file, use_cached=True) -                    attachment_file = File(file, filename=attachment.filename) +                    await attachment.save(file, use_cached=use_cached) +                    attachment_file = discord.File(file, filename=attachment.filename) -                    if isinstance(destination, TextChannel): -                        msg = await destination.send(file=attachment_file) +                    if isinstance(destination, discord.TextChannel): +                        msg = await destination.send(file=attachment_file, **kwargs)                          urls.append(msg.attachments[0].url)                      else: -                        await destination.send( -                            file=attachment_file, -                            username=sub_clyde(message.author.display_name), -                            avatar_url=message.author.avatar_url -                        ) +                        await destination.send(file=attachment_file, **webhook_send_kwargs)              elif link_large:                  large.append(attachment)              else: @@ -99,17 +107,13 @@ async def send_attachments(      if link_large and large:          desc = "\n".join(f"[{attachment.filename}]({attachment.url})" for attachment in large) -        embed = Embed(description=desc) +        embed = discord.Embed(description=desc)          embed.set_footer(text="Attachments exceed upload size limit.") -        if isinstance(destination, TextChannel): -            await destination.send(embed=embed) +        if isinstance(destination, discord.TextChannel): +            await destination.send(embed=embed, **kwargs)          else: -            await destination.send( -                embed=embed, -                username=sub_clyde(message.author.display_name), -                avatar_url=message.author.avatar_url -            ) +            await destination.send(embed=embed, **webhook_send_kwargs)      return urls @@ -133,9 +137,14 @@ def sub_clyde(username: Optional[str]) -> Optional[str]:  async def send_denial(ctx: Context, reason: str) -> None:      """Send an embed denying the user with the given reason.""" -    embed = Embed() -    embed.colour = Colour.red() +    embed = discord.Embed() +    embed.colour = discord.Colour.red()      embed.title = random.choice(NEGATIVE_REPLIES)      embed.description = reason      await ctx.send(embed=embed) + + +def format_user(user: discord.abc.User) -> str: +    """Return a string for `user` which has their mention and ID.""" +    return f"{user.mention} (`{user.id}`)" diff --git a/bot/utils/redis_cache.py b/bot/utils/redis_cache.py deleted file mode 100644 index 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/config-default.yml b/config-default.yml index 58651f548..98c5ff42c 100644 --- a/config-default.yml +++ b/config-default.yml @@ -62,20 +62,6 @@ 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 -          # emotes used for #reddit          upvotes:        "<:reddit_upvotes:755845219890757644>"          comments:       "<:reddit_comments:755845255001014384>" @@ -133,6 +119,7 @@ style:          voice_state_green: "https://cdn.discordapp.com/emojis/656899770094452754.png"          voice_state_red: "https://cdn.discordapp.com/emojis/656899769905709076.png" +  guild:      id: 267624335836053506      invite: "https://discord.gg/python" @@ -141,12 +128,18 @@ guild:          help_available:                     691405807388196926          help_in_use:                        696958401460043776          help_dormant:                       691405908919451718 -        modmail:                            714494672835444826 +        modmail:            &MODMAIL        714494672835444826 +        logs:               &LOGS           468520609152892958      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 @@ -154,8 +147,8 @@ guild:          dev_log:            &DEV_LOG        622895325144940554          # Discussion -        meta:               429409067623251969 -        python_discussion:  267624335836053506 +        meta:                               429409067623251969 +        python_discussion:  &PY_DISCUSSION  267624335836053506          # Python Help: Available          how_to_get_help:    704250143020417084 @@ -177,8 +170,8 @@ guild:          # Special          bot_commands:       &BOT_CMD        267659945086812160          esoteric:                           470884583684964352 -        reddit:                             458224812528238616          verification:                       352442727016693763 +        voice_gate:                         764802555427029012          # Staff          admins:             &ADMINS         365960823622991872 @@ -188,12 +181,20 @@ guild:          incidents:                          714214212200562749          incidents_archive:                  720668923636351037          mods:               &MODS           305126844661760000 -        mod_alerts:         &MOD_ALERTS     473092532147060736 +        mod_alerts:                         473092532147060736          mod_spam:           &MOD_SPAM       620607373828030464          organisation:       &ORGANISATION   551789653284356126          staff_lounge:       &STAFF_LOUNGE   464905259261755392 +        duck_pond:          &DUCK_POND      637820308341915648 + +        # Staff announcement channels +        staff_announcements:    &STAFF_ANNOUNCEMENTS    464033278631084042 +        mod_announcements:      &MOD_ANNOUNCEMENTS      372115205867700225 +        admin_announcements:    &ADMIN_ANNOUNCEMENTS    749736155569848370          # Voice +        code_help_voice:                    755154969761677312 +        code_help_voice_2:                  766330079135268884          admins_voice:       &ADMINS_VOICE   500734494840717332          staff_voice:        &STAFF_VOICE    412375055910043655 @@ -201,19 +202,13 @@ guild:          big_brother_logs:   &BB_LOGS        468507907357409333          talent_pool:        &TALENT_POOL    534321732593647616 -    staff_channels: -        - *ADMINS -        - *ADMIN_SPAM -        - *DEFCON -        - *HELPERS -        - *MODS -        - *MOD_SPAM -        - *ORGANISATION +    moderation_categories: +        - *MODMAIL +        - *LOGS      moderation_channels:          - *ADMINS          - *ADMIN_SPAM -        - *MOD_ALERTS          - *MODS          - *MOD_SPAM @@ -237,9 +232,11 @@ guild:          muted:              &MUTED_ROLE         277914926603829249          partners:                               323426753857191936          python_community:   &PY_COMMUNITY_ROLE  458226413825294336 +        sprinters:          &SPRINTERS          758422482289426471          unverified:                             739794855945044069          verified:                               352427296948486144  # @Developers on PyDis +        voice_verified:                         764802720779337729          # Staff          admins:             &ADMINS_ROLE    267628507062992896 @@ -273,19 +270,22 @@ guild:          reddit:                             635408384794951680          talent_pool:                        569145364800602132 +  filter:      # What do we filter? -    filter_zalgo:       false -    filter_invites:     true -    filter_domains:     true -    watch_regex:        true -    watch_rich_embeds:  true +    filter_zalgo:          false +    filter_invites:        true +    filter_domains:        true +    filter_everyone_ping:  true +    watch_regex:           true +    watch_rich_embeds:     true      # Notify user on filter?      # Notifications are not expected for "watchlist" type filters -    notify_user_zalgo:       false -    notify_user_invites:     true -    notify_user_domains:     false +    notify_user_zalgo:          false +    notify_user_invites:        true +    notify_user_domains:        false +    notify_user_everyone_ping:  true      # Filter configuration      ping_everyone:             true @@ -308,6 +308,7 @@ filter:          - *OWNERS_ROLE          - *HELPERS_ROLE          - *PY_COMMUNITY_ROLE +        - *SPRINTERS  keys: @@ -336,6 +337,7 @@ urls:      bot_avatar:      "https://raw.githubusercontent.com/discord-python/branding/master/logos/logo_circle/logo_circle.png"      github_bot_repo: "https://github.com/python-discord/bot" +  anti_spam:      # Clean messages that violate a rule.      clean_offending: true @@ -391,12 +393,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: @@ -410,6 +406,23 @@ big_brother:      header_message_limit: 15 +code_block: +    # The channels in which code blocks will be detected. They are not subject to a cooldown. +    channel_whitelist: +        - *BOT_CMD + +    # The channels which will be affected by a cooldown. These channels are also whitelisted. +    cooldown_channels: +        - *PY_DISCUSSION + +    # Sending instructions triggers a cooldown on a per-channel basis. +    # More instruction messages will not be sent in the same channel until the cooldown has elapsed. +    cooldown_seconds: 300 + +    # The minimum amount of lines a message or code block must have for instructions to be sent. +    minimum_lines: 4 + +  free:      # Seconds to elapse for a channel      # to be considered inactive. @@ -458,36 +471,34 @@ help_channels:      notify_roles:          - *HELPERS_ROLE +  redirect_output:      delete_invocation: true      delete_delay: 15 -sync: -    confirm_timeout: 300 -    max_diff: 10  duck_pond: -    threshold: 5 -    custom_emojis: -        - *DUCKY_YELLOW -        - *DUCKY_BLURPLE -        - *DUCKY_CAMO -        - *DUCKY_DEVIL -        - *DUCKY_NINJA -        - *DUCKY_REGAL -        - *DUCKY_TUBE -        - *DUCKY_HUNT -        - *DUCKY_WIZARD -        - *DUCKY_PARTY -        - *DUCKY_ANGEL -        - *DUCKY_MAUL -        - *DUCKY_SANTA +    threshold: 4 +    channel_blacklist: +        - *ANNOUNCEMENTS +        - *PYNEWS_CHANNEL +        - *PYEVENTS_CHANNEL +        - *MAILING_LISTS +        - *REDDIT_CHANNEL +        - *USER_EVENT_A +        - *DUCK_POND +        - *CHANGE_LOG +        - *STAFF_ANNOUNCEMENTS +        - *MOD_ANNOUNCEMENTS +        - *ADMIN_ANNOUNCEMENTS +  python_news:      mail_lists:          - 'python-ideas'          - 'python-announce-list'          - 'pypi-announce' +        - 'python-dev'      channel: *PYNEWS_CHANNEL      webhook: *PYNEWS_WEBHOOK @@ -504,5 +515,11 @@ verification:      kick_confirmation_threshold: 0.01  # 1% +voice_gate: +    minimum_days_verified: 3  # How many days the user must have been verified for +    minimum_messages: 50  # How many messages a user must have to be eligible for voice +    bot_message_delete_delay: 10  # Seconds before deleting bot's response in Voice Gate + +  config:      required_keys: ['bot.token'] diff --git a/docker-compose.yml b/docker-compose.yml index cff7d33d6..8be5aac0e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -41,6 +41,7 @@ services:        - postgres      environment:        DATABASE_URL: postgres://pysite:pysite@postgres:5432/pysite +      METRICITY_DB_URL: postgres://pysite:pysite@postgres:5432/metricity        SECRET_KEY: suitable-for-development-only        STATIC_ROOT: /var/www/static diff --git a/tests/_autospec.py b/tests/_autospec.py new file mode 100644 index 000000000..ee2fc1973 --- /dev/null +++ b/tests/_autospec.py @@ -0,0 +1,64 @@ +import contextlib +import functools +import unittest.mock +from typing import Callable + + [email protected](unittest.mock._patch.decoration_helper) +def _decoration_helper(self, patched, args, keywargs): +    """Skips adding patchings as args if their `dont_pass` attribute is True.""" +    # Don't ask what this does. It's just a copy from stdlib, but with the dont_pass check added. +    extra_args = [] +    with contextlib.ExitStack() as exit_stack: +        for patching in patched.patchings: +            arg = exit_stack.enter_context(patching) +            if not getattr(patching, "dont_pass", False): +                # Only add the patching as an arg if dont_pass is False. +                if patching.attribute_name is not None: +                    keywargs.update(arg) +                elif patching.new is unittest.mock.DEFAULT: +                    extra_args.append(arg) + +        args += tuple(extra_args) +        yield args, keywargs + + [email protected](unittest.mock._patch.copy) +def _copy(self): +    """Copy the `dont_pass` attribute along with the standard copy operation.""" +    patcher_copy = _copy.original(self) +    patcher_copy.dont_pass = getattr(self, "dont_pass", False) +    return patcher_copy + + +# Monkey-patch the patcher class :) +_copy.original = unittest.mock._patch.copy +unittest.mock._patch.copy = _copy +unittest.mock._patch.decoration_helper = _decoration_helper + + +def autospec(target, *attributes: str, pass_mocks: bool = True, **patch_kwargs) -> Callable: +    """ +    Patch multiple `attributes` of a `target` with autospecced mocks and `spec_set` as True. + +    If `pass_mocks` is True, pass the autospecced mocks as arguments to the decorated object. +    """ +    # Caller's kwargs should take priority and overwrite the defaults. +    kwargs = dict(spec_set=True, autospec=True) +    kwargs.update(patch_kwargs) + +    # Import the target if it's a string. +    # This is to support both object and string targets like patch.multiple. +    if type(target) is str: +        target = unittest.mock._importer(target) + +    def decorator(func): +        for attribute in attributes: +            patcher = unittest.mock.patch.object(target, attribute, **kwargs) +            if not pass_mocks: +                # A custom attribute to keep track of which patchings should be skipped. +                patcher.dont_pass = True +            func = patcher(func) +        return func +    return decorator diff --git a/tests/bot/exts/backend/sync/test_base.py b/tests/bot/exts/backend/sync/test_base.py index 886c243cf..4953550f9 100644 --- a/tests/bot/exts/backend/sync/test_base.py +++ b/tests/bot/exts/backend/sync/test_base.py @@ -1,12 +1,9 @@ -import asyncio  import unittest  from unittest import mock -import discord -from bot import constants  from bot.api import ResponseCodeError -from bot.exts.backend.sync._syncers import Syncer, _Diff +from bot.exts.backend.sync._syncers import Syncer  from tests import helpers @@ -30,280 +27,16 @@ class SyncerBaseTests(unittest.TestCase):              Syncer(self.bot) -class SyncerSendPromptTests(unittest.IsolatedAsyncioTestCase): -    """Tests for sending the sync confirmation prompt.""" - -    def setUp(self): -        self.bot = helpers.MockBot() -        self.syncer = TestSyncer(self.bot) - -    def mock_get_channel(self): -        """Fixture to return a mock channel and message for when `get_channel` is used.""" -        self.bot.reset_mock() - -        mock_channel = helpers.MockTextChannel() -        mock_message = helpers.MockMessage() - -        mock_channel.send.return_value = mock_message -        self.bot.get_channel.return_value = mock_channel - -        return mock_channel, mock_message - -    def mock_fetch_channel(self): -        """Fixture to return a mock channel and message for when `fetch_channel` is used.""" -        self.bot.reset_mock() - -        mock_channel = helpers.MockTextChannel() -        mock_message = helpers.MockMessage() - -        self.bot.get_channel.return_value = None -        mock_channel.send.return_value = mock_message -        self.bot.fetch_channel.return_value = mock_channel - -        return mock_channel, mock_message - -    async def test_send_prompt_edits_and_returns_message(self): -        """The given message should be edited to display the prompt and then should be returned.""" -        msg = helpers.MockMessage() -        ret_val = await self.syncer._send_prompt(msg) - -        msg.edit.assert_called_once() -        self.assertIn("content", msg.edit.call_args[1]) -        self.assertEqual(ret_val, msg) - -    async def test_send_prompt_gets_dev_core_channel(self): -        """The dev-core channel should be retrieved if an extant message isn't given.""" -        subtests = ( -            (self.bot.get_channel, self.mock_get_channel), -            (self.bot.fetch_channel, self.mock_fetch_channel), -        ) - -        for method, mock_ in subtests: -            with self.subTest(method=method, msg=mock_.__name__): -                mock_() -                await self.syncer._send_prompt() - -                method.assert_called_once_with(constants.Channels.dev_core) - -    async def test_send_prompt_returns_none_if_channel_fetch_fails(self): -        """None should be returned if there's an HTTPException when fetching the channel.""" -        self.bot.get_channel.return_value = None -        self.bot.fetch_channel.side_effect = discord.HTTPException(mock.MagicMock(), "test error!") - -        ret_val = await self.syncer._send_prompt() - -        self.assertIsNone(ret_val) - -    async def test_send_prompt_sends_and_returns_new_message_if_not_given(self): -        """A new message mentioning core devs should be sent and returned if message isn't given.""" -        for mock_ in (self.mock_get_channel, self.mock_fetch_channel): -            with self.subTest(msg=mock_.__name__): -                mock_channel, mock_message = mock_() -                ret_val = await self.syncer._send_prompt() - -                mock_channel.send.assert_called_once() -                self.assertIn(self.syncer._CORE_DEV_MENTION, mock_channel.send.call_args[0][0]) -                self.assertEqual(ret_val, mock_message) - -    async def test_send_prompt_adds_reactions(self): -        """The message should have reactions for confirmation added.""" -        extant_message = helpers.MockMessage() -        subtests = ( -            (extant_message, lambda: (None, extant_message)), -            (None, self.mock_get_channel), -            (None, self.mock_fetch_channel), -        ) - -        for message_arg, mock_ in subtests: -            subtest_msg = "Extant message" if mock_.__name__ == "<lambda>" else mock_.__name__ - -            with self.subTest(msg=subtest_msg): -                _, mock_message = mock_() -                await self.syncer._send_prompt(message_arg) - -                calls = [mock.call(emoji) for emoji in self.syncer._REACTION_EMOJIS] -                mock_message.add_reaction.assert_has_calls(calls) - - -class SyncerConfirmationTests(unittest.IsolatedAsyncioTestCase): -    """Tests for waiting for a sync confirmation reaction on the prompt.""" - -    def setUp(self): -        self.bot = helpers.MockBot() -        self.syncer = TestSyncer(self.bot) -        self.core_dev_role = helpers.MockRole(id=constants.Roles.core_developers) - -    @staticmethod -    def get_message_reaction(emoji): -        """Fixture to return a mock message an reaction from the given `emoji`.""" -        message = helpers.MockMessage() -        reaction = helpers.MockReaction(emoji=emoji, message=message) - -        return message, reaction - -    def test_reaction_check_for_valid_emoji_and_authors(self): -        """Should return True if authors are identical or are a bot and a core dev, respectively.""" -        user_subtests = ( -            ( -                helpers.MockMember(id=77), -                helpers.MockMember(id=77), -                "identical users", -            ), -            ( -                helpers.MockMember(id=77, bot=True), -                helpers.MockMember(id=43, roles=[self.core_dev_role]), -                "bot author and core-dev reactor", -            ), -        ) - -        for emoji in self.syncer._REACTION_EMOJIS: -            for author, user, msg in user_subtests: -                with self.subTest(author=author, user=user, emoji=emoji, msg=msg): -                    message, reaction = self.get_message_reaction(emoji) -                    ret_val = self.syncer._reaction_check(author, message, reaction, user) - -                    self.assertTrue(ret_val) - -    def test_reaction_check_for_invalid_reactions(self): -        """Should return False for invalid reaction events.""" -        valid_emoji = self.syncer._REACTION_EMOJIS[0] -        subtests = ( -            ( -                helpers.MockMember(id=77), -                *self.get_message_reaction(valid_emoji), -                helpers.MockMember(id=43, roles=[self.core_dev_role]), -                "users are not identical", -            ), -            ( -                helpers.MockMember(id=77, bot=True), -                *self.get_message_reaction(valid_emoji), -                helpers.MockMember(id=43), -                "reactor lacks the core-dev role", -            ), -            ( -                helpers.MockMember(id=77, bot=True, roles=[self.core_dev_role]), -                *self.get_message_reaction(valid_emoji), -                helpers.MockMember(id=77, bot=True, roles=[self.core_dev_role]), -                "reactor is a bot", -            ), -            ( -                helpers.MockMember(id=77), -                helpers.MockMessage(id=95), -                helpers.MockReaction(emoji=valid_emoji, message=helpers.MockMessage(id=26)), -                helpers.MockMember(id=77), -                "messages are not identical", -            ), -            ( -                helpers.MockMember(id=77), -                *self.get_message_reaction("InVaLiD"), -                helpers.MockMember(id=77), -                "emoji is invalid", -            ), -        ) - -        for *args, msg in subtests: -            kwargs = dict(zip(("author", "message", "reaction", "user"), args)) -            with self.subTest(**kwargs, msg=msg): -                ret_val = self.syncer._reaction_check(*args) -                self.assertFalse(ret_val) - -    async def test_wait_for_confirmation(self): -        """The message should always be edited and only return True if the emoji is a check mark.""" -        subtests = ( -            (constants.Emojis.check_mark, True, None), -            ("InVaLiD", False, None), -            (None, False, asyncio.TimeoutError), -        ) - -        for emoji, ret_val, side_effect in subtests: -            for bot in (True, False): -                with self.subTest(emoji=emoji, ret_val=ret_val, side_effect=side_effect, bot=bot): -                    # Set up mocks -                    message = helpers.MockMessage() -                    member = helpers.MockMember(bot=bot) - -                    self.bot.wait_for.reset_mock() -                    self.bot.wait_for.return_value = (helpers.MockReaction(emoji=emoji), None) -                    self.bot.wait_for.side_effect = side_effect - -                    # Call the function -                    actual_return = await self.syncer._wait_for_confirmation(member, message) - -                    # Perform assertions -                    self.bot.wait_for.assert_called_once() -                    self.assertIn("reaction_add", self.bot.wait_for.call_args[0]) - -                    message.edit.assert_called_once() -                    kwargs = message.edit.call_args[1] -                    self.assertIn("content", kwargs) - -                    # Core devs should only be mentioned if the author is a bot. -                    if bot: -                        self.assertIn(self.syncer._CORE_DEV_MENTION, kwargs["content"]) -                    else: -                        self.assertNotIn(self.syncer._CORE_DEV_MENTION, kwargs["content"]) - -                    self.assertIs(actual_return, ret_val) - -  class SyncerSyncTests(unittest.IsolatedAsyncioTestCase):      """Tests for main function orchestrating the sync."""      def setUp(self):          self.bot = helpers.MockBot(user=helpers.MockMember(bot=True))          self.syncer = TestSyncer(self.bot) +        self.guild = helpers.MockGuild() -    async def test_sync_respects_confirmation_result(self): -        """The sync should abort if confirmation fails and continue if confirmed.""" -        mock_message = helpers.MockMessage() -        subtests = ( -            (True, mock_message), -            (False, None), -        ) - -        for confirmed, message in subtests: -            with self.subTest(confirmed=confirmed): -                self.syncer._sync.reset_mock() -                self.syncer._get_diff.reset_mock() - -                diff = _Diff({1, 2, 3}, {4, 5}, None) -                self.syncer._get_diff.return_value = diff -                self.syncer._get_confirmation_result = mock.AsyncMock( -                    return_value=(confirmed, message) -                ) - -                guild = helpers.MockGuild() -                await self.syncer.sync(guild) - -                self.syncer._get_diff.assert_called_once_with(guild) -                self.syncer._get_confirmation_result.assert_called_once() - -                if confirmed: -                    self.syncer._sync.assert_called_once_with(diff) -                else: -                    self.syncer._sync.assert_not_called() - -    async def test_sync_diff_size(self): -        """The diff size should be correctly calculated.""" -        subtests = ( -            (6, _Diff({1, 2}, {3, 4}, {5, 6})), -            (5, _Diff({1, 2, 3}, None, {4, 5})), -            (0, _Diff(None, None, None)), -            (0, _Diff(set(), set(), set())), -        ) - -        for size, diff in subtests: -            with self.subTest(size=size, diff=diff): -                self.syncer._get_diff.reset_mock() -                self.syncer._get_diff.return_value = diff -                self.syncer._get_confirmation_result = mock.AsyncMock(return_value=(False, None)) - -                guild = helpers.MockGuild() -                await self.syncer.sync(guild) - -                self.syncer._get_diff.assert_called_once_with(guild) -                self.syncer._get_confirmation_result.assert_called_once() -                self.assertEqual(self.syncer._get_confirmation_result.call_args[0][0], size) +        # Make sure `_get_diff` returns a MagicMock, not an AsyncMock +        self.syncer._get_diff.return_value = mock.MagicMock()      async def test_sync_message_edited(self):          """The message should be edited if one was sent, even if the sync has an API error.""" @@ -316,89 +49,25 @@ class SyncerSyncTests(unittest.IsolatedAsyncioTestCase):          for message, side_effect, should_edit in subtests:              with self.subTest(message=message, side_effect=side_effect, should_edit=should_edit):                  self.syncer._sync.side_effect = side_effect -                self.syncer._get_confirmation_result = mock.AsyncMock( -                    return_value=(True, message) -                ) +                ctx = helpers.MockContext() +                ctx.send.return_value = message -                guild = helpers.MockGuild() -                await self.syncer.sync(guild) +                await self.syncer.sync(self.guild, ctx)                  if should_edit:                      message.edit.assert_called_once()                      self.assertIn("content", message.edit.call_args[1]) -    async def test_sync_confirmation_context_redirect(self): -        """If ctx is given, a new message should be sent and author should be ctx's author.""" -        mock_member = helpers.MockMember() +    async def test_sync_message_sent(self): +        """If ctx is given, a new message should be sent."""          subtests = ( -            (None, self.bot.user, None), -            (helpers.MockContext(author=mock_member), mock_member, helpers.MockMessage()), +            (None, None), +            (helpers.MockContext(), helpers.MockMessage()),          ) -        for ctx, author, message in subtests: -            with self.subTest(ctx=ctx, author=author, message=message): -                if ctx is not None: -                    ctx.send.return_value = message - -                # Make sure `_get_diff` returns a MagicMock, not an AsyncMock -                self.syncer._get_diff.return_value = mock.MagicMock() - -                self.syncer._get_confirmation_result = mock.AsyncMock(return_value=(False, None)) - -                guild = helpers.MockGuild() -                await self.syncer.sync(guild, ctx) +        for ctx, message in subtests: +            with self.subTest(ctx=ctx, message=message): +                await self.syncer.sync(self.guild, ctx)                  if ctx is not None:                      ctx.send.assert_called_once() - -                self.syncer._get_confirmation_result.assert_called_once() -                self.assertEqual(self.syncer._get_confirmation_result.call_args[0][1], author) -                self.assertEqual(self.syncer._get_confirmation_result.call_args[0][2], message) - -    @mock.patch.object(constants.Sync, "max_diff", new=3) -    async def test_confirmation_result_small_diff(self): -        """Should always return True and the given message if the diff size is too small.""" -        author = helpers.MockMember() -        expected_message = helpers.MockMessage() - -        for size in (3, 2):  # pragma: no cover -            with self.subTest(size=size): -                self.syncer._send_prompt = mock.AsyncMock() -                self.syncer._wait_for_confirmation = mock.AsyncMock() - -                coro = self.syncer._get_confirmation_result(size, author, expected_message) -                result, actual_message = await coro - -                self.assertTrue(result) -                self.assertEqual(actual_message, expected_message) -                self.syncer._send_prompt.assert_not_called() -                self.syncer._wait_for_confirmation.assert_not_called() - -    @mock.patch.object(constants.Sync, "max_diff", new=3) -    async def test_confirmation_result_large_diff(self): -        """Should return True if confirmed and False if _send_prompt fails or aborted.""" -        author = helpers.MockMember() -        mock_message = helpers.MockMessage() - -        subtests = ( -            (True, mock_message, True, "confirmed"), -            (False, None, False, "_send_prompt failed"), -            (False, mock_message, False, "aborted"), -        ) - -        for expected_result, expected_message, confirmed, msg in subtests:  # pragma: no cover -            with self.subTest(msg=msg): -                self.syncer._send_prompt = mock.AsyncMock(return_value=expected_message) -                self.syncer._wait_for_confirmation = mock.AsyncMock(return_value=confirmed) - -                coro = self.syncer._get_confirmation_result(4, author) -                actual_result, actual_message = await coro - -                self.syncer._send_prompt.assert_called_once_with(None)  # message defaults to None -                self.assertIs(actual_result, expected_result) -                self.assertEqual(actual_message, expected_message) - -                if expected_message: -                    self.syncer._wait_for_confirmation.assert_called_once_with( -                        author, expected_message -                    ) diff --git a/tests/bot/exts/backend/sync/test_cog.py b/tests/bot/exts/backend/sync/test_cog.py index 1b89564f2..063a82754 100644 --- a/tests/bot/exts/backend/sync/test_cog.py +++ b/tests/bot/exts/backend/sync/test_cog.py @@ -392,14 +392,14 @@ class SyncCogCommandTests(SyncCogTestCase, CommandTestCase):      async def test_sync_roles_command(self):          """sync() should be called on the RoleSyncer."""          ctx = helpers.MockContext() -        await self.cog.sync_roles_command.callback(self.cog, ctx) +        await self.cog.sync_roles_command(self.cog, ctx)          self.cog.role_syncer.sync.assert_called_once_with(ctx.guild, ctx)      async def test_sync_users_command(self):          """sync() should be called on the UserSyncer."""          ctx = helpers.MockContext() -        await self.cog.sync_users_command.callback(self.cog, ctx) +        await self.cog.sync_users_command(self.cog, ctx)          self.cog.user_syncer.sync.assert_called_once_with(ctx.guild, ctx) diff --git a/tests/bot/exts/backend/sync/test_users.py b/tests/bot/exts/backend/sync/test_users.py index c0a1da35c..9f380a15d 100644 --- a/tests/bot/exts/backend/sync/test_users.py +++ b/tests/bot/exts/backend/sync/test_users.py @@ -1,7 +1,6 @@  import unittest -from unittest import mock -from bot.exts.backend.sync._syncers import UserSyncer, _Diff, _User +from bot.exts.backend.sync._syncers import UserSyncer, _Diff  from tests import helpers @@ -10,7 +9,7 @@ def fake_user(**kwargs):      kwargs.setdefault("id", 43)      kwargs.setdefault("name", "bob the test man")      kwargs.setdefault("discriminator", 1337) -    kwargs.setdefault("roles", (666,)) +    kwargs.setdefault("roles", [666])      kwargs.setdefault("in_guild", True)      return kwargs @@ -40,22 +39,42 @@ class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase):          return guild +    @staticmethod +    def get_mock_member(member: dict): +        member = member.copy() +        del member["in_guild"] +        mock_member = helpers.MockMember(**member) +        mock_member.roles = [helpers.MockRole(id=role_id) for role_id in member["roles"]] +        return mock_member +      async def test_empty_diff_for_no_users(self):          """When no users are given, an empty diff should be returned.""" +        self.bot.api_client.get.return_value = { +            "count": 3, +            "next_page_no": None, +            "previous_page_no": None, +            "results": [] +        }          guild = self.get_guild()          actual_diff = await self.syncer._get_diff(guild) -        expected_diff = (set(), set(), None) +        expected_diff = ([], [], None)          self.assertEqual(actual_diff, expected_diff)      async def test_empty_diff_for_identical_users(self):          """No differences should be found if the users in the guild and DB are identical.""" -        self.bot.api_client.get.return_value = [fake_user()] +        self.bot.api_client.get.return_value = { +            "count": 3, +            "next_page_no": None, +            "previous_page_no": None, +            "results": [fake_user()] +        }          guild = self.get_guild(fake_user()) +        guild.get_member.return_value = self.get_mock_member(fake_user())          actual_diff = await self.syncer._get_diff(guild) -        expected_diff = (set(), set(), None) +        expected_diff = ([], [], None)          self.assertEqual(actual_diff, expected_diff) @@ -63,59 +82,102 @@ class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase):          """Only updated users should be added to the 'updated' set of the diff."""          updated_user = fake_user(id=99, name="new") -        self.bot.api_client.get.return_value = [fake_user(id=99, name="old"), fake_user()] +        self.bot.api_client.get.return_value = { +            "count": 3, +            "next_page_no": None, +            "previous_page_no": None, +            "results": [fake_user(id=99, name="old"), fake_user()] +        }          guild = self.get_guild(updated_user, fake_user()) +        guild.get_member.side_effect = [ +            self.get_mock_member(updated_user), +            self.get_mock_member(fake_user()) +        ]          actual_diff = await self.syncer._get_diff(guild) -        expected_diff = (set(), {_User(**updated_user)}, None) +        expected_diff = ([], [{"id": 99, "name": "new"}], None)          self.assertEqual(actual_diff, expected_diff)      async def test_diff_for_new_users(self): -        """Only new users should be added to the 'created' set of the diff.""" +        """Only new users should be added to the 'created' list of the diff."""          new_user = fake_user(id=99, name="new") -        self.bot.api_client.get.return_value = [fake_user()] +        self.bot.api_client.get.return_value = { +            "count": 3, +            "next_page_no": None, +            "previous_page_no": None, +            "results": [fake_user()] +        }          guild = self.get_guild(fake_user(), new_user) - +        guild.get_member.side_effect = [ +            self.get_mock_member(fake_user()), +            self.get_mock_member(new_user) +        ]          actual_diff = await self.syncer._get_diff(guild) -        expected_diff = ({_User(**new_user)}, set(), None) +        expected_diff = ([new_user], [], None)          self.assertEqual(actual_diff, expected_diff)      async def test_diff_sets_in_guild_false_for_leaving_users(self):          """When a user leaves the guild, the `in_guild` flag is updated to `False`.""" -        leaving_user = fake_user(id=63, in_guild=False) - -        self.bot.api_client.get.return_value = [fake_user(), fake_user(id=63)] +        self.bot.api_client.get.return_value = { +            "count": 3, +            "next_page_no": None, +            "previous_page_no": None, +            "results": [fake_user(), fake_user(id=63)] +        }          guild = self.get_guild(fake_user()) +        guild.get_member.side_effect = [ +            self.get_mock_member(fake_user()), +            None +        ]          actual_diff = await self.syncer._get_diff(guild) -        expected_diff = (set(), {_User(**leaving_user)}, None) +        expected_diff = ([], [{"id": 63, "in_guild": False}], None)          self.assertEqual(actual_diff, expected_diff)      async def test_diff_for_new_updated_and_leaving_users(self):          """When users are added, updated, and removed, all of them are returned properly."""          new_user = fake_user(id=99, name="new") +          updated_user = fake_user(id=55, name="updated") -        leaving_user = fake_user(id=63, in_guild=False) -        self.bot.api_client.get.return_value = [fake_user(), fake_user(id=55), fake_user(id=63)] +        self.bot.api_client.get.return_value = { +            "count": 3, +            "next_page_no": None, +            "previous_page_no": None, +            "results": [fake_user(), fake_user(id=55), fake_user(id=63)] +        }          guild = self.get_guild(fake_user(), new_user, updated_user) +        guild.get_member.side_effect = [ +            self.get_mock_member(fake_user()), +            self.get_mock_member(updated_user), +            None +        ]          actual_diff = await self.syncer._get_diff(guild) -        expected_diff = ({_User(**new_user)}, {_User(**updated_user), _User(**leaving_user)}, None) +        expected_diff = ([new_user], [{"id": 55, "name": "updated"}, {"id": 63, "in_guild": False}], None)          self.assertEqual(actual_diff, expected_diff)      async def test_empty_diff_for_db_users_not_in_guild(self): -        """When the DB knows a user the guild doesn't, no difference is found.""" -        self.bot.api_client.get.return_value = [fake_user(), fake_user(id=63, in_guild=False)] +        """When the DB knows a user, but the guild doesn't, no difference is found.""" +        self.bot.api_client.get.return_value = { +            "count": 3, +            "next_page_no": None, +            "previous_page_no": None, +            "results": [fake_user(), fake_user(id=63, in_guild=False)] +        }          guild = self.get_guild(fake_user()) +        guild.get_member.side_effect = [ +            self.get_mock_member(fake_user()), +            None +        ]          actual_diff = await self.syncer._get_diff(guild) -        expected_diff = (set(), set(), None) +        expected_diff = ([], [], None)          self.assertEqual(actual_diff, expected_diff) @@ -131,13 +193,10 @@ class UserSyncerSyncTests(unittest.IsolatedAsyncioTestCase):          """Only POST requests should be made with the correct payload."""          users = [fake_user(id=111), fake_user(id=222)] -        user_tuples = {_User(**user) for user in users} -        diff = _Diff(user_tuples, set(), None) +        diff = _Diff(users, [], None)          await self.syncer._sync(diff) -        calls = [mock.call("bot/users", json=user) for user in users] -        self.bot.api_client.post.assert_has_calls(calls, any_order=True) -        self.assertEqual(self.bot.api_client.post.call_count, len(users)) +        self.bot.api_client.post.assert_called_once_with("bot/users", json=diff.created)          self.bot.api_client.put.assert_not_called()          self.bot.api_client.delete.assert_not_called() @@ -146,13 +205,10 @@ class UserSyncerSyncTests(unittest.IsolatedAsyncioTestCase):          """Only PUT requests should be made with the correct payload."""          users = [fake_user(id=111), fake_user(id=222)] -        user_tuples = {_User(**user) for user in users} -        diff = _Diff(set(), user_tuples, None) +        diff = _Diff([], users, None)          await self.syncer._sync(diff) -        calls = [mock.call(f"bot/users/{user['id']}", json=user) for user in users] -        self.bot.api_client.put.assert_has_calls(calls, any_order=True) -        self.assertEqual(self.bot.api_client.put.call_count, len(users)) +        self.bot.api_client.patch.assert_called_once_with("bot/users/bulk_patch", json=diff.updated)          self.bot.api_client.post.assert_not_called()          self.bot.api_client.delete.assert_not_called() diff --git a/tests/bot/exts/filters/test_token_remover.py b/tests/bot/exts/filters/test_token_remover.py index a0ff8a877..f99cc3370 100644 --- a/tests/bot/exts/filters/test_token_remover.py +++ b/tests/bot/exts/filters/test_token_remover.py @@ -9,6 +9,7 @@ from bot import constants  from bot.exts.filters import token_remover  from bot.exts.filters.token_remover import Token, TokenRemover  from bot.exts.moderation.modlog import ModLog +from bot.utils.messages import format_user  from tests.helpers import MockBot, MockMessage, autospec @@ -22,23 +23,25 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase):          self.msg = MockMessage(id=555, content="hello world")          self.msg.channel.mention = "#lemonade-stand" +        self.msg.guild.get_member.return_value.bot = False +        self.msg.guild.get_member.return_value.__str__.return_value = "Woody"          self.msg.author.__str__ = MagicMock(return_value=self.msg.author.name)          self.msg.author.avatar_url_as.return_value = "picture-lemon.png" -    def test_is_valid_user_id_valid(self): -        """Should consider user IDs valid if they decode entirely to ASCII digits.""" -        ids = ( -            "NDcyMjY1OTQzMDYyNDEzMzMy", -            "NDc1MDczNjI5Mzk5NTQ3OTA0", -            "NDY3MjIzMjMwNjUwNzc3NjQx", +    def test_extract_user_id_valid(self): +        """Should consider user IDs valid if they decode into an integer ID.""" +        id_pairs = ( +            ("NDcyMjY1OTQzMDYyNDEzMzMy", 472265943062413332), +            ("NDc1MDczNjI5Mzk5NTQ3OTA0", 475073629399547904), +            ("NDY3MjIzMjMwNjUwNzc3NjQx", 467223230650777641),          ) -        for user_id in ids: -            with self.subTest(user_id=user_id): -                result = TokenRemover.is_valid_user_id(user_id) -                self.assertTrue(result) +        for token_id, user_id in id_pairs: +            with self.subTest(token_id=token_id): +                result = TokenRemover.extract_user_id(token_id) +                self.assertEqual(result, user_id) -    def test_is_valid_user_id_invalid(self): +    def test_extract_user_id_invalid(self):          """Should consider non-digit and non-ASCII IDs invalid."""          ids = (              ("SGVsbG8gd29ybGQ", "non-digit ASCII"), @@ -52,8 +55,8 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase):          for user_id, msg in ids:              with self.subTest(msg=msg): -                result = TokenRemover.is_valid_user_id(user_id) -                self.assertFalse(result) +                result = TokenRemover.extract_user_id(user_id) +                self.assertIsNone(result)      def test_is_valid_timestamp_valid(self):          """Should consider timestamps valid if they're greater than the Discord epoch.""" @@ -85,6 +88,34 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase):                  result = TokenRemover.is_valid_timestamp(timestamp)                  self.assertFalse(result) +    def test_is_valid_hmac_valid(self): +        """Should consider an HMAC valid if it has at least 3 unique characters.""" +        valid_hmacs = ( +            "VXmErH7j511turNpfURmb0rVNm8", +            "Ysnu2wacjaKs7qnoo46S8Dm2us8", +            "sJf6omBPORBPju3WJEIAcwW9Zds", +            "s45jqDV_Iisn-symw0yDRrk_jf4", +        ) + +        for hmac in valid_hmacs: +            with self.subTest(msg=hmac): +                result = TokenRemover.is_maybe_valid_hmac(hmac) +                self.assertTrue(result) + +    def test_is_invalid_hmac_invalid(self): +        """Should consider an HMAC invalid if has fewer than 3 unique characters.""" +        invalid_hmacs = ( +            ("xxxxxxxxxxxxxxxxxx", "Single character"), +            ("XxXxXxXxXxXxXxXxXx", "Single character alternating case"), +            ("ASFasfASFasfASFASsf", "Three characters alternating-case"), +            ("asdasdasdasdasdasdasd", "Three characters one case"), +        ) + +        for hmac, msg in invalid_hmacs: +            with self.subTest(msg=msg): +                result = TokenRemover.is_maybe_valid_hmac(hmac) +                self.assertFalse(result) +      def test_mod_log_property(self):          """The `mod_log` property should ask the bot to return the `ModLog` cog."""          self.bot.get_cog.return_value = 'lemon' @@ -142,11 +173,18 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase):          self.assertIsNone(return_value)          token_re.finditer.assert_called_once_with(self.msg.content) -    @autospec(TokenRemover, "is_valid_user_id", "is_valid_timestamp") +    @autospec(TokenRemover, "extract_user_id", "is_valid_timestamp", "is_maybe_valid_hmac")      @autospec("bot.exts.filters.token_remover", "Token")      @autospec("bot.exts.filters.token_remover", "TOKEN_RE") -    def test_find_token_valid_match(self, token_re, token_cls, is_valid_id, is_valid_timestamp): -        """The first match with a valid user ID and timestamp should be returned as a `Token`.""" +    def test_find_token_valid_match( +        self, +        token_re, +        token_cls, +        extract_user_id, +        is_valid_timestamp, +        is_maybe_valid_hmac, +    ): +        """The first match with a valid user ID, timestamp, and HMAC should be returned as a `Token`."""          matches = [              mock.create_autospec(Match, spec_set=True, instance=True),              mock.create_autospec(Match, spec_set=True, instance=True), @@ -158,23 +196,32 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase):          token_re.finditer.return_value = matches          token_cls.side_effect = tokens -        is_valid_id.side_effect = (False, True)  # The 1st match will be invalid, 2nd one valid. +        extract_user_id.side_effect = (None, True)  # The 1st match will be invalid, 2nd one valid.          is_valid_timestamp.return_value = True +        is_maybe_valid_hmac.return_value = True          return_value = TokenRemover.find_token_in_message(self.msg)          self.assertEqual(tokens[1], return_value)          token_re.finditer.assert_called_once_with(self.msg.content) -    @autospec(TokenRemover, "is_valid_user_id", "is_valid_timestamp") +    @autospec(TokenRemover, "extract_user_id", "is_valid_timestamp", "is_maybe_valid_hmac")      @autospec("bot.exts.filters.token_remover", "Token")      @autospec("bot.exts.filters.token_remover", "TOKEN_RE") -    def test_find_token_invalid_matches(self, token_re, token_cls, is_valid_id, is_valid_timestamp): -        """None should be returned if no matches have valid user IDs or timestamps.""" +    def test_find_token_invalid_matches( +        self, +        token_re, +        token_cls, +        extract_user_id, +        is_valid_timestamp, +        is_maybe_valid_hmac, +    ): +        """None should be returned if no matches have valid user IDs, HMACs, and timestamps."""          token_re.finditer.return_value = [mock.create_autospec(Match, spec_set=True, instance=True)]          token_cls.return_value = mock.create_autospec(Token, spec_set=True, instance=True) -        is_valid_id.return_value = False +        extract_user_id.return_value = None          is_valid_timestamp.return_value = False +        is_maybe_valid_hmac.return_value = False          return_value = TokenRemover.find_token_in_message(self.msg) @@ -233,33 +280,82 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase):      @autospec("bot.exts.filters.token_remover", "LOG_MESSAGE")      def test_format_log_message(self, log_message):          """Should correctly format the log message with info from the message and token.""" -        token = Token("NDY3MjIzMjMwNjUwNzc3NjQx", "XsySD_", "s45jqDV_Iisn-symw0yDRrk_jf4") +        token = Token("NDcyMjY1OTQzMDYyNDEzMzMy", "XsySD_", "s45jqDV_Iisn-symw0yDRrk_jf4")          log_message.format.return_value = "Howdy"          return_value = TokenRemover.format_log_message(self.msg, token)          self.assertEqual(return_value, log_message.format.return_value)          log_message.format.assert_called_once_with( -            author=self.msg.author, -            author_id=self.msg.author.id, +            author=format_user(self.msg.author),              channel=self.msg.channel.mention,              user_id=token.user_id,              timestamp=token.timestamp,              hmac="x" * len(token.hmac),          ) +    @autospec("bot.exts.filters.token_remover", "UNKNOWN_USER_LOG_MESSAGE") +    def test_format_userid_log_message_unknown(self, unknown_user_log_message): +        """Should correctly format the user ID portion when the actual user it belongs to is unknown.""" +        token = Token("NDcyMjY1OTQzMDYyNDEzMzMy", "XsySD_", "s45jqDV_Iisn-symw0yDRrk_jf4") +        unknown_user_log_message.format.return_value = " Partner" +        msg = MockMessage(id=555, content="hello world") +        msg.guild.get_member.return_value = None + +        return_value = TokenRemover.format_userid_log_message(msg, token) + +        self.assertEqual(return_value, (unknown_user_log_message.format.return_value, False)) +        unknown_user_log_message.format.assert_called_once_with(user_id=472265943062413332) + +    @autospec("bot.exts.filters.token_remover", "KNOWN_USER_LOG_MESSAGE") +    def test_format_userid_log_message_bot(self, known_user_log_message): +        """Should correctly format the user ID portion when the ID belongs to a known bot.""" +        token = Token("NDcyMjY1OTQzMDYyNDEzMzMy", "XsySD_", "s45jqDV_Iisn-symw0yDRrk_jf4") +        known_user_log_message.format.return_value = " Partner" +        msg = MockMessage(id=555, content="hello world") +        msg.guild.get_member.return_value.__str__.return_value = "Sam" +        msg.guild.get_member.return_value.bot = True + +        return_value = TokenRemover.format_userid_log_message(msg, token) + +        self.assertEqual(return_value, (known_user_log_message.format.return_value, False)) + +        known_user_log_message.format.assert_called_once_with( +            user_id=472265943062413332, +            user_name="Sam", +            kind="BOT", +        ) + +    @autospec("bot.exts.filters.token_remover", "KNOWN_USER_LOG_MESSAGE") +    def test_format_log_message_user_token_user(self, user_token_message): +        """Should correctly format the user ID portion when the ID belongs to a known user.""" +        token = Token("NDY3MjIzMjMwNjUwNzc3NjQx", "XsySD_", "s45jqDV_Iisn-symw0yDRrk_jf4") +        user_token_message.format.return_value = "Partner" + +        return_value = TokenRemover.format_userid_log_message(self.msg, token) + +        self.assertEqual(return_value, (user_token_message.format.return_value, True)) +        user_token_message.format.assert_called_once_with( +            user_id=467223230650777641, +            user_name="Woody", +            kind="USER", +        ) +      @mock.patch.object(TokenRemover, "mod_log", new_callable=mock.PropertyMock)      @autospec("bot.exts.filters.token_remover", "log") -    @autospec(TokenRemover, "format_log_message") -    async def test_take_action(self, format_log_message, logger, mod_log_property): +    @autospec(TokenRemover, "format_log_message", "format_userid_log_message") +    async def test_take_action(self, format_log_message, format_userid_log_message, logger, mod_log_property):          """Should delete the message and send a mod log."""          cog = TokenRemover(self.bot)          mod_log = mock.create_autospec(ModLog, spec_set=True, instance=True)          token = mock.create_autospec(Token, spec_set=True, instance=True) +        token.user_id = "no-id"          log_msg = "testing123" +        userid_log_message = "userid-log-message"          mod_log_property.return_value = mod_log          format_log_message.return_value = log_msg +        format_userid_log_message.return_value = (userid_log_message, True)          await cog.take_action(self.msg, token) @@ -269,6 +365,7 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase):          )          format_log_message.assert_called_once_with(self.msg, token) +        format_userid_log_message.assert_called_once_with(self.msg, token)          logger.debug.assert_called_with(log_msg)          self.bot.stats.incr.assert_called_once_with("tokens.removed_tokens") @@ -277,9 +374,10 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase):              icon_url=constants.Icons.token_removed,              colour=Colour(constants.Colours.soft_red),              title="Token removed!", -            text=log_msg, +            text=log_msg + "\n" + userid_log_message,              thumbnail=self.msg.author.avatar_url_as.return_value, -            channel_id=constants.Channels.mod_alerts +            channel_id=constants.Channels.mod_alerts, +            ping_everyone=True,          )      @mock.patch.object(TokenRemover, "mod_log", new_callable=mock.PropertyMock) diff --git a/tests/bot/exts/fun/__init__.py b/tests/bot/exts/fun/__init__.py deleted file mode 100644 index e69de29bb..000000000 --- a/tests/bot/exts/fun/__init__.py +++ /dev/null diff --git a/tests/bot/exts/fun/test_duck_pond.py b/tests/bot/exts/fun/test_duck_pond.py deleted file mode 100644 index 704b08066..000000000 --- a/tests/bot/exts/fun/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.exts.fun import duck_pond -from tests import base -from tests import helpers - -MODULE_PATH = "bot.exts.fun.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(MODULE_PATH) -        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(MODULE_PATH) - -        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(MODULE_PATH) - -        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/info/test_information.py b/tests/bot/exts/info/test_information.py index 74cbac4b6..daede54c5 100644 --- a/tests/bot/exts/info/test_information.py +++ b/tests/bot/exts/info/test_information.py @@ -1,4 +1,3 @@ -import asyncio  import textwrap  import unittest  import unittest.mock @@ -13,7 +12,7 @@ from tests import helpers  COG_PATH = "bot.exts.info.information.Information" -class InformationCogTests(unittest.TestCase): +class InformationCogTests(unittest.IsolatedAsyncioTestCase):      """Tests the Information cog."""      @classmethod @@ -29,16 +28,14 @@ class InformationCogTests(unittest.TestCase):          self.ctx = helpers.MockContext()          self.ctx.author.roles.append(self.moderator_role) -    def test_roles_command_command(self): +    async def test_roles_command_command(self):          """Test if the `role_info` command correctly returns the `moderator_role`."""          self.ctx.guild.roles.append(self.moderator_role)          self.cog.roles_info.can_run = unittest.mock.AsyncMock()          self.cog.roles_info.can_run.return_value = True -        coroutine = self.cog.roles_info.callback(self.cog, self.ctx) - -        self.assertIsNone(asyncio.run(coroutine)) +        self.assertIsNone(await self.cog.roles_info(self.cog, self.ctx))          self.ctx.send.assert_called_once()          _, kwargs = self.ctx.send.call_args @@ -48,7 +45,7 @@ class InformationCogTests(unittest.TestCase):          self.assertEqual(embed.colour, discord.Colour.blurple())          self.assertEqual(embed.description, f"\n`{self.moderator_role.id}` - {self.moderator_role.mention}\n") -    def test_role_info_command(self): +    async def test_role_info_command(self):          """Tests the `role info` command."""          dummy_role = helpers.MockRole(              name="Dummy", @@ -73,9 +70,7 @@ class InformationCogTests(unittest.TestCase):          self.cog.role_info.can_run = unittest.mock.AsyncMock()          self.cog.role_info.can_run.return_value = True -        coroutine = self.cog.role_info.callback(self.cog, self.ctx, dummy_role, admin_role) - -        self.assertIsNone(asyncio.run(coroutine)) +        self.assertIsNone(await self.cog.role_info(self.cog, self.ctx, dummy_role, admin_role))          self.assertEqual(self.ctx.send.call_count, 2) @@ -97,108 +92,8 @@ class InformationCogTests(unittest.TestCase):          self.assertEqual(admin_embed.title, "Admins info")          self.assertEqual(admin_embed.colour, discord.Colour.red()) -    @unittest.mock.patch('bot.exts.info.information.time_since') -    def test_server_info_command(self, time_since_patch): -        time_since_patch.return_value = '2 days ago' - -        self.ctx.guild = helpers.MockGuild( -            features=('lemons', 'apples'), -            region="The Moon", -            roles=[self.moderator_role], -            channels=[ -                discord.TextChannel( -                    state={}, -                    guild=self.ctx.guild, -                    data={'id': 42, 'name': 'lemons-offering', 'position': 22, 'type': 'text'} -                ), -                discord.CategoryChannel( -                    state={}, -                    guild=self.ctx.guild, -                    data={'id': 5125, 'name': 'the-lemon-collection', 'position': 22, 'type': 'category'} -                ), -                discord.VoiceChannel( -                    state={}, -                    guild=self.ctx.guild, -                    data={'id': 15290, 'name': 'listen-to-lemon', 'position': 22, 'type': 'voice'} -                ) -            ], -            members=[ -                *(helpers.MockMember(status=discord.Status.online) for _ in range(2)), -                *(helpers.MockMember(status=discord.Status.idle) for _ in range(1)), -                *(helpers.MockMember(status=discord.Status.dnd) for _ in range(4)), -                *(helpers.MockMember(status=discord.Status.offline) for _ in range(3)), -            ], -            member_count=1_234, -            icon_url='a-lemon.jpg', -        ) - -        self.ctx.guild.get_role = unittest.mock.Mock() -        self.ctx.guild.get_role.side_effect = lambda id: { -            constants.Roles.helpers: helpers.MockRole(name="Helpers", id=id, members=[]), -            constants.Roles.moderators: helpers.MockRole(name="Moderators", id=id, members=[]), -            constants.Roles.admins: helpers.MockRole(name="Admins", id=id, members=[]), -            constants.Roles.owners: helpers.MockRole(name="Owners", id=id, members=[]), -            constants.Roles.contributors: helpers.MockRole(name="Contributors", id=id, members=[]), -        }[id] - -        coroutine = self.cog.server_info.callback(self.cog, self.ctx) -        self.assertIsNone(asyncio.run(coroutine)) - -        time_since_patch.assert_called_once_with(self.ctx.guild.created_at, precision='days') -        _, kwargs = self.ctx.send.call_args -        embed = kwargs.pop('embed') -        self.assertEqual(embed.colour, discord.Colour.blurple()) -        self.assertEqual(embed.title, "Server Information") -        self.assertEqual( -            embed.description, -            textwrap.dedent( -                f""" -                Created: {time_since_patch.return_value} -                Voice region: {self.ctx.guild.region} -                Roles: {len(self.ctx.guild.roles) - 1} -                """ -            ) -        ) - -        # Members -        member_field = embed.fields[0] -        self.assertEqual(member_field.name, f"Members: {self.ctx.guild.member_count}") -        self.assertEqual( -            member_field.value, -            textwrap.dedent(""" -                Helpers: 0 -                Moderators: 0 -                Admins: 0 -                Owners: 0 -                Contributors: 0 -            """).strip(), -        ) -        # Channels -        channel_field = embed.fields[1] -        self.assertEqual(channel_field.name, "Channels: 3") -        self.assertEqual( -            channel_field.value, -            textwrap.dedent(""" -                Category: 1 -                Text: 1 -                Voice: 1 -            """).strip(), -        ) - -        # Member status -        status_field = embed.fields[2] -        self.assertEqual(status_field.name, "Member Status:") -        self.assertEqual( -            status_field.value, -            f"{constants.Emojis.status_online} 2 {constants.Emojis.status_idle} 1 " -            f"{constants.Emojis.status_dnd} 4 {constants.Emojis.status_offline} 3" -        ) - -        self.assertEqual(embed.thumbnail.url, 'a-lemon.jpg') - - -class UserInfractionHelperMethodTests(unittest.TestCase): +class UserInfractionHelperMethodTests(unittest.IsolatedAsyncioTestCase):      """Tests for the helper methods of the `!user` command."""      def setUp(self): @@ -208,7 +103,7 @@ class UserInfractionHelperMethodTests(unittest.TestCase):          self.cog = information.Information(self.bot)          self.member = helpers.MockMember(id=1234) -    def test_user_command_helper_method_get_requests(self): +    async def test_user_command_helper_method_get_requests(self):          """The helper methods should form the correct get requests."""          test_values = (              { @@ -230,11 +125,11 @@ class UserInfractionHelperMethodTests(unittest.TestCase):              endpoint, params = test_value["expected_args"]              with self.subTest(method=helper_method, endpoint=endpoint, params=params): -                asyncio.run(helper_method(self.member)) +                await helper_method(self.member)                  self.bot.api_client.get.assert_called_once_with(endpoint, params=params)                  self.bot.api_client.get.reset_mock() -    def _method_subtests(self, method, test_values, default_header): +    async def _method_subtests(self, method, test_values, default_header):          """Helper method that runs the subtests for the different helper methods."""          for test_value in test_values:              api_response = test_value["api response"] @@ -244,11 +139,11 @@ class UserInfractionHelperMethodTests(unittest.TestCase):                  self.bot.api_client.get.return_value = api_response                  expected_output = "\n".join(expected_lines) -                actual_output = asyncio.run(method(self.member)) +                actual_output = await method(self.member)                  self.assertEqual((default_header, expected_output), actual_output) -    def test_basic_user_infraction_counts_returns_correct_strings(self): +    async def test_basic_user_infraction_counts_returns_correct_strings(self):          """The method should correctly list both the total and active number of non-hidden infractions."""          test_values = (              # No infractions means zero counts @@ -279,9 +174,9 @@ class UserInfractionHelperMethodTests(unittest.TestCase):          header = "Infractions" -        self._method_subtests(self.cog.basic_user_infraction_counts, test_values, header) +        await self._method_subtests(self.cog.basic_user_infraction_counts, test_values, header) -    def test_expanded_user_infraction_counts_returns_correct_strings(self): +    async def test_expanded_user_infraction_counts_returns_correct_strings(self):          """The method should correctly list the total and active number of all infractions split by infraction type."""          test_values = (              { @@ -334,9 +229,9 @@ class UserInfractionHelperMethodTests(unittest.TestCase):          header = "Infractions" -        self._method_subtests(self.cog.expanded_user_infraction_counts, test_values, header) +        await self._method_subtests(self.cog.expanded_user_infraction_counts, test_values, header) -    def test_user_nomination_counts_returns_correct_strings(self): +    async def test_user_nomination_counts_returns_correct_strings(self):          """The method should list the number of active and historical nominations for the user."""          test_values = (              { @@ -364,12 +259,12 @@ class UserInfractionHelperMethodTests(unittest.TestCase):          header = "Nominations" -        self._method_subtests(self.cog.user_nomination_counts, test_values, header) +        await self._method_subtests(self.cog.user_nomination_counts, test_values, header)  @unittest.mock.patch("bot.exts.info.information.time_since", new=unittest.mock.MagicMock(return_value="1 year ago"))  @unittest.mock.patch("bot.exts.info.information.constants.MODERATION_CHANNELS", new=[50]) -class UserEmbedTests(unittest.TestCase): +class UserEmbedTests(unittest.IsolatedAsyncioTestCase):      """Tests for the creation of the `!user` embed."""      def setUp(self): @@ -382,14 +277,14 @@ class UserEmbedTests(unittest.TestCase):          f"{COG_PATH}.basic_user_infraction_counts",          new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions"))      ) -    def test_create_user_embed_uses_string_representation_of_user_in_title_if_nick_is_not_available(self): +    async def test_create_user_embed_uses_string_representation_of_user_in_title_if_nick_is_not_available(self):          """The embed should use the string representation of the user if they don't have a nick."""          ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1))          user = helpers.MockMember()          user.nick = None          user.__str__ = unittest.mock.Mock(return_value="Mr. Hemlock") -        embed = asyncio.run(self.cog.create_user_embed(ctx, user)) +        embed = await self.cog.create_user_embed(ctx, user)          self.assertEqual(embed.title, "Mr. Hemlock") @@ -397,14 +292,14 @@ class UserEmbedTests(unittest.TestCase):          f"{COG_PATH}.basic_user_infraction_counts",          new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions"))      ) -    def test_create_user_embed_uses_nick_in_title_if_available(self): +    async def test_create_user_embed_uses_nick_in_title_if_available(self):          """The embed should use the nick if it's available."""          ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1))          user = helpers.MockMember()          user.nick = "Cat lover"          user.__str__ = unittest.mock.Mock(return_value="Mr. Hemlock") -        embed = asyncio.run(self.cog.create_user_embed(ctx, user)) +        embed = await self.cog.create_user_embed(ctx, user)          self.assertEqual(embed.title, "Cat lover (Mr. Hemlock)") @@ -412,7 +307,7 @@ class UserEmbedTests(unittest.TestCase):          f"{COG_PATH}.basic_user_infraction_counts",          new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions"))      ) -    def test_create_user_embed_ignores_everyone_role(self): +    async def test_create_user_embed_ignores_everyone_role(self):          """Created `!user` embeds should not contain mention of the @everyone-role."""          ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1))          admins_role = helpers.MockRole(name='Admins') @@ -421,14 +316,18 @@ class UserEmbedTests(unittest.TestCase):          # A `MockMember` has the @Everyone role by default; we add the Admins to that.          user = helpers.MockMember(roles=[admins_role], top_role=admins_role) -        embed = asyncio.run(self.cog.create_user_embed(ctx, user)) +        embed = await self.cog.create_user_embed(ctx, user)          self.assertIn("&Admins", embed.fields[1].value)          self.assertNotIn("&Everyone", embed.fields[1].value)      @unittest.mock.patch(f"{COG_PATH}.expanded_user_infraction_counts", new_callable=unittest.mock.AsyncMock)      @unittest.mock.patch(f"{COG_PATH}.user_nomination_counts", new_callable=unittest.mock.AsyncMock) -    def test_create_user_embed_expanded_information_in_moderation_channels(self, nomination_counts, infraction_counts): +    async def test_create_user_embed_expanded_information_in_moderation_channels( +            self, +            nomination_counts, +            infraction_counts +    ):          """The embed should contain expanded infractions and nomination info in mod channels."""          ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=50)) @@ -439,7 +338,7 @@ class UserEmbedTests(unittest.TestCase):          nomination_counts.return_value = ("Nominations", "nomination info")          user = helpers.MockMember(id=314, roles=[moderators_role], top_role=moderators_role) -        embed = asyncio.run(self.cog.create_user_embed(ctx, user)) +        embed = await self.cog.create_user_embed(ctx, user)          infraction_counts.assert_called_once_with(user)          nomination_counts.assert_called_once_with(user) @@ -462,7 +361,7 @@ class UserEmbedTests(unittest.TestCase):          )      @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new_callable=unittest.mock.AsyncMock) -    def test_create_user_embed_basic_information_outside_of_moderation_channels(self, infraction_counts): +    async def test_create_user_embed_basic_information_outside_of_moderation_channels(self, infraction_counts):          """The embed should contain only basic infraction data outside of mod channels."""          ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=100)) @@ -472,7 +371,7 @@ class UserEmbedTests(unittest.TestCase):          infraction_counts.return_value = ("Infractions", "basic infractions info")          user = helpers.MockMember(id=314, roles=[moderators_role], top_role=moderators_role) -        embed = asyncio.run(self.cog.create_user_embed(ctx, user)) +        embed = await self.cog.create_user_embed(ctx, user)          infraction_counts.assert_called_once_with(user) @@ -495,14 +394,14 @@ class UserEmbedTests(unittest.TestCase):          self.assertEqual(              "basic infractions info", -            embed.fields[3].value +            embed.fields[2].value          )      @unittest.mock.patch(          f"{COG_PATH}.basic_user_infraction_counts",          new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions"))      ) -    def test_create_user_embed_uses_top_role_colour_when_user_has_roles(self): +    async def test_create_user_embed_uses_top_role_colour_when_user_has_roles(self):          """The embed should be created with the colour of the top role, if a top role is available."""          ctx = helpers.MockContext() @@ -510,7 +409,7 @@ class UserEmbedTests(unittest.TestCase):          moderators_role.colour = 100          user = helpers.MockMember(id=314, roles=[moderators_role], top_role=moderators_role) -        embed = asyncio.run(self.cog.create_user_embed(ctx, user)) +        embed = await self.cog.create_user_embed(ctx, user)          self.assertEqual(embed.colour, discord.Colour(moderators_role.colour)) @@ -518,12 +417,12 @@ class UserEmbedTests(unittest.TestCase):          f"{COG_PATH}.basic_user_infraction_counts",          new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions"))      ) -    def test_create_user_embed_uses_blurple_colour_when_user_has_no_roles(self): +    async def test_create_user_embed_uses_blurple_colour_when_user_has_no_roles(self):          """The embed should be created with a blurple colour if the user has no assigned roles."""          ctx = helpers.MockContext()          user = helpers.MockMember(id=217) -        embed = asyncio.run(self.cog.create_user_embed(ctx, user)) +        embed = await self.cog.create_user_embed(ctx, user)          self.assertEqual(embed.colour, discord.Colour.blurple()) @@ -531,20 +430,20 @@ class UserEmbedTests(unittest.TestCase):          f"{COG_PATH}.basic_user_infraction_counts",          new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions"))      ) -    def test_create_user_embed_uses_png_format_of_user_avatar_as_thumbnail(self): +    async def test_create_user_embed_uses_png_format_of_user_avatar_as_thumbnail(self):          """The embed thumbnail should be set to the user's avatar in `png` format."""          ctx = helpers.MockContext()          user = helpers.MockMember(id=217)          user.avatar_url_as.return_value = "avatar url" -        embed = asyncio.run(self.cog.create_user_embed(ctx, user)) +        embed = await self.cog.create_user_embed(ctx, user)          user.avatar_url_as.assert_called_once_with(static_format="png")          self.assertEqual(embed.thumbnail.url, "avatar url")  @unittest.mock.patch("bot.exts.info.information.constants") -class UserCommandTests(unittest.TestCase): +class UserCommandTests(unittest.IsolatedAsyncioTestCase):      """Tests for the `!user` command."""      def setUp(self): @@ -560,76 +459,70 @@ class UserCommandTests(unittest.TestCase):          self.moderator = helpers.MockMember(id=2, name="riffautae", roles=[self.moderator_role])          self.target = helpers.MockMember(id=3, name="__fluzz__") -    def test_regular_member_cannot_target_another_member(self, constants): +        # There's no way to mock the channel constant without deferring imports. The constant is +        # used as a default value for a parameter, which gets defined upon import. +        self.bot_command_channel = helpers.MockTextChannel(id=constants.Channels.bot_commands) + +    async def test_regular_member_cannot_target_another_member(self, constants):          """A regular user should not be able to use `!user` targeting another user."""          constants.MODERATION_ROLES = [self.moderator_role.id] -          ctx = helpers.MockContext(author=self.author) -        asyncio.run(self.cog.user_info.callback(self.cog, ctx, self.target)) +        await self.cog.user_info(self.cog, ctx, self.target)          ctx.send.assert_called_once_with("You may not use this command on users other than yourself.") -    def test_regular_member_cannot_use_command_outside_of_bot_commands(self, constants): +    async def test_regular_member_cannot_use_command_outside_of_bot_commands(self, constants):          """A regular user should not be able to use this command outside of bot-commands."""          constants.MODERATION_ROLES = [self.moderator_role.id]          constants.STAFF_ROLES = [self.moderator_role.id] -        constants.Channels.bot_commands = 50 -          ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(id=100))          msg = "Sorry, but you may only use this command within <#50>."          with self.assertRaises(InWhitelistCheckFailure, msg=msg): -            asyncio.run(self.cog.user_info.callback(self.cog, ctx)) +            await self.cog.user_info(self.cog, ctx)      @unittest.mock.patch("bot.exts.info.information.Information.create_user_embed") -    def test_regular_user_may_use_command_in_bot_commands_channel(self, create_embed, constants): +    async def test_regular_user_may_use_command_in_bot_commands_channel(self, create_embed, constants):          """A regular user should be allowed to use `!user` targeting themselves in bot-commands."""          constants.STAFF_ROLES = [self.moderator_role.id] -        constants.Channels.bot_commands = 50 - -        ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(id=50)) +        ctx = helpers.MockContext(author=self.author, channel=self.bot_command_channel) -        asyncio.run(self.cog.user_info.callback(self.cog, ctx)) +        await self.cog.user_info(self.cog, ctx)          create_embed.assert_called_once_with(ctx, self.author)          ctx.send.assert_called_once()      @unittest.mock.patch("bot.exts.info.information.Information.create_user_embed") -    def test_regular_user_can_explicitly_target_themselves(self, create_embed, constants): +    async def test_regular_user_can_explicitly_target_themselves(self, create_embed, _):          """A user should target itself with `!user` when a `user` argument was not provided."""          constants.STAFF_ROLES = [self.moderator_role.id] -        constants.Channels.bot_commands = 50 +        ctx = helpers.MockContext(author=self.author, channel=self.bot_command_channel) -        ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(id=50)) - -        asyncio.run(self.cog.user_info.callback(self.cog, ctx, self.author)) +        await self.cog.user_info(self.cog, ctx, self.author)          create_embed.assert_called_once_with(ctx, self.author)          ctx.send.assert_called_once()      @unittest.mock.patch("bot.exts.info.information.Information.create_user_embed") -    def test_staff_members_can_bypass_channel_restriction(self, create_embed, constants): +    async def test_staff_members_can_bypass_channel_restriction(self, create_embed, constants):          """Staff members should be able to bypass the bot-commands channel restriction."""          constants.STAFF_ROLES = [self.moderator_role.id] -        constants.Channels.bot_commands = 50 -          ctx = helpers.MockContext(author=self.moderator, channel=helpers.MockTextChannel(id=200)) -        asyncio.run(self.cog.user_info.callback(self.cog, ctx)) +        await self.cog.user_info(self.cog, ctx)          create_embed.assert_called_once_with(ctx, self.moderator)          ctx.send.assert_called_once()      @unittest.mock.patch("bot.exts.info.information.Information.create_user_embed") -    def test_moderators_can_target_another_member(self, create_embed, constants): +    async def test_moderators_can_target_another_member(self, create_embed, constants):          """A moderator should be able to use `!user` targeting another user."""          constants.MODERATION_ROLES = [self.moderator_role.id]          constants.STAFF_ROLES = [self.moderator_role.id] -          ctx = helpers.MockContext(author=self.moderator, channel=helpers.MockTextChannel(id=50)) -        asyncio.run(self.cog.user_info.callback(self.cog, ctx, self.target)) +        await self.cog.user_info(self.cog, ctx, self.target)          create_embed.assert_called_once_with(ctx, self.target)          ctx.send.assert_called_once() diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index be1b649e1..bf557a484 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -1,7 +1,8 @@  import textwrap  import unittest -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import AsyncMock, MagicMock, Mock, patch +from bot.constants import Event  from bot.exts.moderation.infraction.infractions import Infractions  from tests.helpers import MockBot, MockContext, MockGuild, MockMember, MockRole @@ -53,3 +54,148 @@ class TruncationTests(unittest.IsolatedAsyncioTestCase):          self.cog.apply_infraction.assert_awaited_once_with(              self.ctx, {"foo": "bar"}, self.target, self.target.kick.return_value          ) + + +@patch("bot.exts.moderation.infraction.infractions.constants.Roles.voice_verified", new=123456) +class VoiceBanTests(unittest.IsolatedAsyncioTestCase): +    """Tests for voice ban related functions and commands.""" + +    def setUp(self): +        self.bot = MockBot() +        self.mod = MockMember(top_role=10) +        self.user = MockMember(top_role=1, roles=[MockRole(id=123456)]) +        self.guild = MockGuild() +        self.ctx = MockContext(bot=self.bot, author=self.mod) +        self.cog = Infractions(self.bot) + +    async def test_permanent_voice_ban(self): +        """Should call voice ban applying function without expiry.""" +        self.cog.apply_voice_ban = AsyncMock() +        self.assertIsNone(await self.cog.voiceban(self.cog, self.ctx, self.user, reason="foobar")) +        self.cog.apply_voice_ban.assert_awaited_once_with(self.ctx, self.user, "foobar") + +    async def test_temporary_voice_ban(self): +        """Should call voice ban applying function with expiry.""" +        self.cog.apply_voice_ban = AsyncMock() +        self.assertIsNone(await self.cog.tempvoiceban(self.cog, self.ctx, self.user, "baz", reason="foobar")) +        self.cog.apply_voice_ban.assert_awaited_once_with(self.ctx, self.user, "foobar", expires_at="baz") + +    async def test_voice_unban(self): +        """Should call infraction pardoning function.""" +        self.cog.pardon_infraction = AsyncMock() +        self.assertIsNone(await self.cog.unvoiceban(self.cog, self.ctx, self.user)) +        self.cog.pardon_infraction.assert_awaited_once_with(self.ctx, "voice_ban", self.user) + +    @patch("bot.exts.moderation.infraction.infractions._utils.post_infraction") +    @patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction") +    async def test_voice_ban_user_have_active_infraction(self, get_active_infraction, post_infraction_mock): +        """Should return early when user already have Voice Ban infraction.""" +        get_active_infraction.return_value = {"foo": "bar"} +        self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar")) +        get_active_infraction.assert_awaited_once_with(self.ctx, self.user, "voice_ban") +        post_infraction_mock.assert_not_awaited() + +    @patch("bot.exts.moderation.infraction.infractions._utils.post_infraction") +    @patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction") +    async def test_voice_ban_infraction_post_failed(self, get_active_infraction, post_infraction_mock): +        """Should return early when posting infraction fails.""" +        self.cog.mod_log.ignore = MagicMock() +        get_active_infraction.return_value = None +        post_infraction_mock.return_value = None +        self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar")) +        post_infraction_mock.assert_awaited_once() +        self.cog.mod_log.ignore.assert_not_called() + +    @patch("bot.exts.moderation.infraction.infractions._utils.post_infraction") +    @patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction") +    async def test_voice_ban_infraction_post_add_kwargs(self, get_active_infraction, post_infraction_mock): +        """Should pass all kwargs passed to apply_voice_ban to post_infraction.""" +        get_active_infraction.return_value = None +        # We don't want that this continue yet +        post_infraction_mock.return_value = None +        self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar", my_kwarg=23)) +        post_infraction_mock.assert_awaited_once_with( +            self.ctx, self.user, "voice_ban", "foobar", active=True, my_kwarg=23 +        ) + +    @patch("bot.exts.moderation.infraction.infractions._utils.post_infraction") +    @patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction") +    async def test_voice_ban_mod_log_ignore(self, get_active_infraction, post_infraction_mock): +        """Should ignore Voice Verified role removing.""" +        self.cog.mod_log.ignore = MagicMock() +        self.cog.apply_infraction = AsyncMock() +        self.user.remove_roles = MagicMock(return_value="my_return_value") + +        get_active_infraction.return_value = None +        post_infraction_mock.return_value = {"foo": "bar"} + +        self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar")) +        self.cog.mod_log.ignore.assert_called_once_with(Event.member_update, self.user.id) + +    @patch("bot.exts.moderation.infraction.infractions._utils.post_infraction") +    @patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction") +    async def test_voice_ban_apply_infraction(self, get_active_infraction, post_infraction_mock): +        """Should ignore Voice Verified role removing.""" +        self.cog.mod_log.ignore = MagicMock() +        self.cog.apply_infraction = AsyncMock() +        self.user.remove_roles = MagicMock(return_value="my_return_value") + +        get_active_infraction.return_value = None +        post_infraction_mock.return_value = {"foo": "bar"} + +        self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar")) +        self.user.remove_roles.assert_called_once_with(self.cog._voice_verified_role, reason="foobar") +        self.cog.apply_infraction.assert_awaited_once_with(self.ctx, {"foo": "bar"}, self.user, "my_return_value") + +    @patch("bot.exts.moderation.infraction.infractions._utils.post_infraction") +    @patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction") +    async def test_voice_ban_truncate_reason(self, get_active_infraction, post_infraction_mock): +        """Should truncate reason for voice ban.""" +        self.cog.mod_log.ignore = MagicMock() +        self.cog.apply_infraction = AsyncMock() +        self.user.remove_roles = MagicMock(return_value="my_return_value") + +        get_active_infraction.return_value = None +        post_infraction_mock.return_value = {"foo": "bar"} + +        self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar" * 3000)) +        self.user.remove_roles.assert_called_once_with( +            self.cog._voice_verified_role, reason=textwrap.shorten("foobar" * 3000, 512, placeholder="...") +        ) +        self.cog.apply_infraction.assert_awaited_once_with(self.ctx, {"foo": "bar"}, self.user, "my_return_value") + +    async def test_voice_unban_user_not_found(self): +        """Should include info to return dict when user was not found from guild.""" +        self.guild.get_member.return_value = None +        result = await self.cog.pardon_voice_ban(self.user.id, self.guild, "foobar") +        self.assertEqual(result, {"Info": "User was not found in the guild."}) + +    @patch("bot.exts.moderation.infraction.infractions._utils.notify_pardon") +    @patch("bot.exts.moderation.infraction.infractions.format_user") +    async def test_voice_unban_user_found(self, format_user_mock, notify_pardon_mock): +        """Should add role back with ignoring, notify user and return log dictionary..""" +        self.guild.get_member.return_value = self.user +        notify_pardon_mock.return_value = True +        format_user_mock.return_value = "my-user" + +        result = await self.cog.pardon_voice_ban(self.user.id, self.guild, "foobar") +        self.assertEqual(result, { +            "Member": "my-user", +            "DM": "Sent" +        }) +        notify_pardon_mock.assert_awaited_once() + +    @patch("bot.exts.moderation.infraction.infractions._utils.notify_pardon") +    @patch("bot.exts.moderation.infraction.infractions.format_user") +    async def test_voice_unban_dm_fail(self, format_user_mock, notify_pardon_mock): +        """Should add role back with ignoring, notify user and return log dictionary..""" +        self.guild.get_member.return_value = self.user +        notify_pardon_mock.return_value = False +        format_user_mock.return_value = "my-user" + +        result = await self.cog.pardon_voice_ban(self.user.id, self.guild, "foobar") +        self.assertEqual(result, { +            "Member": "my-user", +            "DM": "**Failed**" +        }) +        notify_pardon_mock.assert_awaited_once() diff --git a/tests/bot/exts/moderation/infraction/test_utils.py b/tests/bot/exts/moderation/infraction/test_utils.py new file mode 100644 index 000000000..5b62463e0 --- /dev/null +++ b/tests/bot/exts/moderation/infraction/test_utils.py @@ -0,0 +1,359 @@ +import unittest +from collections import namedtuple +from datetime import datetime +from unittest.mock import AsyncMock, MagicMock, call, patch + +from discord import Embed, Forbidden, HTTPException, NotFound + +from bot.api import ResponseCodeError +from bot.constants import Colours, Icons +from bot.exts.moderation.infraction import _utils as utils +from tests.helpers import MockBot, MockContext, MockMember, MockUser + + +class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): +    """Tests Moderation utils.""" + +    def setUp(self): +        self.bot = MockBot() +        self.member = MockMember(id=1234) +        self.user = MockUser(id=1234) +        self.ctx = MockContext(bot=self.bot, author=self.member) + +    async def test_post_user(self): +        """Should POST a new user and return the response if successful or otherwise send an error message.""" +        user = MockUser(discriminator=5678, id=1234, name="Test user") +        not_user = MagicMock(discriminator=3333, id=5678, name="Wrong user") +        test_cases = [ +            { +                "user": user, +                "post_result": "bar", +                "raise_error": None, +                "payload": { +                    "discriminator": 5678, +                    "id": self.user.id, +                    "in_guild": False, +                    "name": "Test user", +                    "roles": [] +                } +            }, +            { +                "user": self.member, +                "post_result": "foo", +                "raise_error": ResponseCodeError(MagicMock(status=400), "foo"), +                "payload": { +                    "discriminator": 0, +                    "id": self.member.id, +                    "in_guild": False, +                    "name": "Name unknown", +                    "roles": [] +                } +            }, +            { +                "user": not_user, +                "post_result": "bar", +                "raise_error": None, +                "payload": { +                    "discriminator": not_user.discriminator, +                    "id": not_user.id, +                    "in_guild": False, +                    "name": not_user.name, +                    "roles": [] +                } +            } +        ] + +        for case in test_cases: +            user = case["user"] +            post_result = case["post_result"] +            raise_error = case["raise_error"] +            payload = case["payload"] + +            with self.subTest(user=user, post_result=post_result, raise_error=raise_error, payload=payload): +                self.bot.api_client.post.reset_mock(side_effect=True) +                self.ctx.bot.api_client.post.return_value = post_result + +                self.ctx.bot.api_client.post.side_effect = raise_error + +                result = await utils.post_user(self.ctx, user) + +                if raise_error: +                    self.assertIsNone(result) +                    self.ctx.send.assert_awaited_once() +                    self.assertIn(str(raise_error.status), self.ctx.send.call_args[0][0]) +                else: +                    self.assertEqual(result, post_result) +                    self.bot.api_client.post.assert_awaited_once_with("bot/users", json=payload) + +    async def test_get_active_infraction(self): +        """ +        Should request the API for active infractions and return infraction if the user has one or `None` otherwise. + +        A message should be sent to the context indicating a user already has an infraction, if that's the case. +        """ +        test_case = namedtuple("test_case", ["get_return_value", "expected_output", "infraction_nr", "send_msg"]) +        test_cases = [ +            test_case([], None, None, True), +            test_case([{"id": 123987}], {"id": 123987}, "123987", False), +            test_case([{"id": 123987}], {"id": 123987}, "123987", True) +        ] + +        for case in test_cases: +            with self.subTest(return_value=case.get_return_value, expected=case.expected_output): +                self.bot.api_client.get.reset_mock() +                self.ctx.send.reset_mock() + +                params = { +                    "active": "true", +                    "type": "ban", +                    "user__id": str(self.member.id) +                } + +                self.bot.api_client.get.return_value = case.get_return_value + +                result = await utils.get_active_infraction(self.ctx, self.member, "ban", send_msg=case.send_msg) +                self.assertEqual(result, case.expected_output) +                self.bot.api_client.get.assert_awaited_once_with("bot/infractions", params=params) + +                if case.send_msg and case.get_return_value: +                    self.ctx.send.assert_awaited_once() +                    sent_message = self.ctx.send.call_args[0][0] +                    self.assertIn(case.infraction_nr, sent_message) +                    self.assertIn("ban", sent_message) +                else: +                    self.ctx.send.assert_not_awaited() + +    @patch("bot.exts.moderation.infraction._utils.send_private_embed") +    async def test_notify_infraction(self, send_private_embed_mock): +        """ +        Should send an embed of a certain format as a DM and return `True` if DM successful. + +        Appealable infractions should have the appeal message in the embed's footer. +        """ +        test_cases = [ +            { +                "args": (self.user, "ban", "2020-02-26 09:20 (23 hours and 59 minutes)"), +                "expected_output": Embed( +                    title=utils.INFRACTION_TITLE, +                    description=utils.INFRACTION_DESCRIPTION_TEMPLATE.format( +                        type="Ban", +                        expires="2020-02-26 09:20 (23 hours and 59 minutes)", +                        reason="No reason provided." +                    ), +                    colour=Colours.soft_red, +                    url=utils.RULES_URL +                ).set_author( +                    name=utils.INFRACTION_AUTHOR_NAME, +                    url=utils.RULES_URL, +                    icon_url=Icons.token_removed +                ).set_footer(text=utils.INFRACTION_APPEAL_FOOTER), +                "send_result": True +            }, +            { +                "args": (self.user, "warning", None, "Test reason."), +                "expected_output": Embed( +                    title=utils.INFRACTION_TITLE, +                    description=utils.INFRACTION_DESCRIPTION_TEMPLATE.format( +                        type="Warning", +                        expires="N/A", +                        reason="Test reason." +                    ), +                    colour=Colours.soft_red, +                    url=utils.RULES_URL +                ).set_author( +                    name=utils.INFRACTION_AUTHOR_NAME, +                    url=utils.RULES_URL, +                    icon_url=Icons.token_removed +                ), +                "send_result": False +            }, +            { +                "args": (self.user, "note", None, None, Icons.defcon_denied), +                "expected_output": Embed( +                    title=utils.INFRACTION_TITLE, +                    description=utils.INFRACTION_DESCRIPTION_TEMPLATE.format( +                        type="Note", +                        expires="N/A", +                        reason="No reason provided." +                    ), +                    colour=Colours.soft_red, +                    url=utils.RULES_URL +                ).set_author( +                    name=utils.INFRACTION_AUTHOR_NAME, +                    url=utils.RULES_URL, +                    icon_url=Icons.defcon_denied +                ), +                "send_result": False +            }, +            { +                "args": (self.user, "mute", "2020-02-26 09:20 (23 hours and 59 minutes)", "Test", Icons.defcon_denied), +                "expected_output": Embed( +                    title=utils.INFRACTION_TITLE, +                    description=utils.INFRACTION_DESCRIPTION_TEMPLATE.format( +                        type="Mute", +                        expires="2020-02-26 09:20 (23 hours and 59 minutes)", +                        reason="Test" +                    ), +                    colour=Colours.soft_red, +                    url=utils.RULES_URL +                ).set_author( +                    name=utils.INFRACTION_AUTHOR_NAME, +                    url=utils.RULES_URL, +                    icon_url=Icons.defcon_denied +                ).set_footer(text=utils.INFRACTION_APPEAL_FOOTER), +                "send_result": False +            }, +            { +                "args": (self.user, "mute", None, "foo bar" * 4000, Icons.defcon_denied), +                "expected_output": Embed( +                    title=utils.INFRACTION_TITLE, +                    description=utils.INFRACTION_DESCRIPTION_TEMPLATE.format( +                        type="Mute", +                        expires="N/A", +                        reason="foo bar" * 4000 +                    )[:2045] + "...", +                    colour=Colours.soft_red, +                    url=utils.RULES_URL +                ).set_author( +                    name=utils.INFRACTION_AUTHOR_NAME, +                    url=utils.RULES_URL, +                    icon_url=Icons.defcon_denied +                ).set_footer(text=utils.INFRACTION_APPEAL_FOOTER), +                "send_result": True +            } +        ] + +        for case in test_cases: +            with self.subTest(args=case["args"], expected=case["expected_output"], send=case["send_result"]): +                send_private_embed_mock.reset_mock() + +                send_private_embed_mock.return_value = case["send_result"] +                result = await utils.notify_infraction(*case["args"]) + +                self.assertEqual(case["send_result"], result) + +                embed = send_private_embed_mock.call_args[0][1] + +                self.assertEqual(embed.to_dict(), case["expected_output"].to_dict()) + +                send_private_embed_mock.assert_awaited_once_with(case["args"][0], embed) + +    @patch("bot.exts.moderation.infraction._utils.send_private_embed") +    async def test_notify_pardon(self, send_private_embed_mock): +        """Should send an embed of a certain format as a DM and return `True` if DM successful.""" +        test_case = namedtuple("test_case", ["args", "icon", "send_result"]) +        test_cases = [ +            test_case((self.user, "Test title", "Example content"), Icons.user_verified, True), +            test_case((self.user, "Test title", "Example content", Icons.user_update), Icons.user_update, False) +        ] + +        for case in test_cases: +            expected = Embed( +                description="Example content", +                colour=Colours.soft_green +            ).set_author( +                name="Test title", +                icon_url=case.icon +            ) + +            with self.subTest(args=case.args, expected=expected): +                send_private_embed_mock.reset_mock() + +                send_private_embed_mock.return_value = case.send_result + +                result = await utils.notify_pardon(*case.args) +                self.assertEqual(case.send_result, result) + +                embed = send_private_embed_mock.call_args[0][1] +                self.assertEqual(embed.to_dict(), expected.to_dict()) + +                send_private_embed_mock.assert_awaited_once_with(case.args[0], embed) + +    async def test_send_private_embed(self): +        """Should DM the user and return `True` on success or `False` on failure.""" +        embed = Embed(title="Test", description="Test val") + +        test_case = namedtuple("test_case", ["expected_output", "raised_exception"]) +        test_cases = [ +            test_case(True, None), +            test_case(False, HTTPException(AsyncMock(), AsyncMock())), +            test_case(False, Forbidden(AsyncMock(), AsyncMock())), +            test_case(False, NotFound(AsyncMock(), AsyncMock())) +        ] + +        for case in test_cases: +            with self.subTest(expected=case.expected_output, raised=case.raised_exception): +                self.user.send.reset_mock(side_effect=True) +                self.user.send.side_effect = case.raised_exception + +                result = await utils.send_private_embed(self.user, embed) + +                self.assertEqual(result, case.expected_output) +                self.user.send.assert_awaited_once_with(embed=embed) + + +class TestPostInfraction(unittest.IsolatedAsyncioTestCase): +    """Tests for the `post_infraction` function.""" + +    def setUp(self): +        self.bot = MockBot() +        self.member = MockMember(id=1234) +        self.user = MockUser(id=1234) +        self.ctx = MockContext(bot=self.bot, author=self.member) + +    async def test_normal_post_infraction(self): +        """Should return response from POST request if there are no errors.""" +        now = datetime.now() +        payload = { +            "actor": self.ctx.author.id, +            "hidden": True, +            "reason": "Test reason", +            "type": "ban", +            "user": self.member.id, +            "active": False, +            "expires_at": now.isoformat() +        } + +        self.ctx.bot.api_client.post.return_value = "foo" +        actual = await utils.post_infraction(self.ctx, self.member, "ban", "Test reason", now, True, False) + +        self.assertEqual(actual, "foo") +        self.ctx.bot.api_client.post.assert_awaited_once_with("bot/infractions", json=payload) + +    async def test_unknown_error_post_infraction(self): +        """Should send an error message to chat when a non-400 error occurs.""" +        self.ctx.bot.api_client.post.side_effect = ResponseCodeError(AsyncMock(), AsyncMock()) +        self.ctx.bot.api_client.post.side_effect.status = 500 + +        actual = await utils.post_infraction(self.ctx, self.user, "ban", "Test reason") +        self.assertIsNone(actual) + +        self.assertTrue("500" in self.ctx.send.call_args[0][0]) + +    @patch("bot.exts.moderation.infraction._utils.post_user", return_value=None) +    async def test_user_not_found_none_post_infraction(self, post_user_mock): +        """Should abort and return `None` when a new user fails to be posted.""" +        self.bot.api_client.post.side_effect = ResponseCodeError(MagicMock(status=400), {"user": "foo"}) + +        actual = await utils.post_infraction(self.ctx, self.user, "mute", "Test reason") +        self.assertIsNone(actual) +        post_user_mock.assert_awaited_once_with(self.ctx, self.user) + +    @patch("bot.exts.moderation.infraction._utils.post_user", return_value="bar") +    async def test_first_fail_second_success_user_post_infraction(self, post_user_mock): +        """Should post the user if they don't exist, POST infraction again, and return the response if successful.""" +        payload = { +            "actor": self.ctx.author.id, +            "hidden": False, +            "reason": "Test reason", +            "type": "mute", +            "user": self.user.id, +            "active": True +        } + +        self.bot.api_client.post.side_effect = [ResponseCodeError(MagicMock(status=400), {"user": "foo"}), "foo"] + +        actual = await utils.post_infraction(self.ctx, self.user, "mute", "Test reason") +        self.assertEqual(actual, "foo") +        self.bot.api_client.post.assert_has_awaits([call("bot/infractions", json=payload)] * 2) +        post_user_mock.assert_awaited_once_with(self.ctx, self.user) diff --git a/tests/bot/exts/moderation/test_silence.py b/tests/bot/exts/moderation/test_silence.py index 8c4fb764a..104293d8e 100644 --- a/tests/bot/exts/moderation/test_silence.py +++ b/tests/bot/exts/moderation/test_silence.py @@ -1,23 +1,49 @@ +import asyncio  import unittest +from datetime import datetime, timezone  from unittest import mock -from unittest.mock import MagicMock, Mock +from unittest.mock import Mock +from async_rediscache import RedisSession  from discord import PermissionOverwrite -from bot.constants import Channels, Emojis, Guild, Roles -from bot.exts.moderation.silence import Silence, SilenceNotifier -from tests.helpers import MockBot, MockContext, MockTextChannel +from bot.constants import Channels, Guild, Roles +from bot.exts.moderation import silence +from tests.helpers import MockBot, MockContext, MockTextChannel, autospec + +redis_session = None +redis_loop = asyncio.get_event_loop() + + +def setUpModule():  # noqa: N802 +    """Create and connect to the fakeredis session.""" +    global redis_session +    redis_session = RedisSession(use_fakeredis=True) +    redis_loop.run_until_complete(redis_session.connect()) + + +def tearDownModule():  # noqa: N802 +    """Close the fakeredis session.""" +    if redis_session: +        redis_loop.run_until_complete(redis_session.close()) + + +# Have to subclass it because builtins can't be patched. +class PatchedDatetime(datetime): +    """A datetime object with a mocked now() function.""" + +    now = mock.create_autospec(datetime, "now")  class SilenceNotifierTests(unittest.IsolatedAsyncioTestCase):      def setUp(self) -> None:          self.alert_channel = MockTextChannel() -        self.notifier = SilenceNotifier(self.alert_channel) +        self.notifier = silence.SilenceNotifier(self.alert_channel)          self.notifier.stop = self.notifier_stop_mock = Mock()          self.notifier.start = self.notifier_start_mock = Mock()      def test_add_channel_adds_channel(self): -        """Channel in FirstHash with current loop is added to internal set.""" +        """Channel is added to `_silenced_channels` with the current loop."""          channel = Mock()          with mock.patch.object(self.notifier, "_silenced_channels") as silenced_channels:              self.notifier.add_channel(channel) @@ -35,7 +61,7 @@ class SilenceNotifierTests(unittest.IsolatedAsyncioTestCase):          self.notifier_start_mock.assert_not_called()      def test_remove_channel_removes_channel(self): -        """Channel in FirstHash is removed from `_silenced_channels`.""" +        """Channel is removed from `_silenced_channels`."""          channel = Mock()          with mock.patch.object(self.notifier, "_silenced_channels") as silenced_channels:              self.notifier.remove_channel(channel) @@ -59,7 +85,9 @@ class SilenceNotifierTests(unittest.IsolatedAsyncioTestCase):              with self.subTest(current_loop=current_loop):                  with mock.patch.object(self.notifier, "_current_loop", new=current_loop):                      await self.notifier._notifier() -                self.alert_channel.send.assert_called_once_with(f"<@&{Roles.moderators}> currently silenced channels: ") +                self.alert_channel.send.assert_called_once_with( +                    f"<@&{Roles.moderators}> currently silenced channels: " +                )              self.alert_channel.send.reset_mock()      async def test_notifier_skips_alert(self): @@ -72,190 +100,403 @@ class SilenceNotifierTests(unittest.IsolatedAsyncioTestCase):                      self.alert_channel.send.assert_not_called() -class SilenceTests(unittest.IsolatedAsyncioTestCase): +@autospec(silence.Silence, "previous_overwrites", "unsilence_timestamps", pass_mocks=False) +class SilenceCogTests(unittest.IsolatedAsyncioTestCase): +    """Tests for the general functionality of the Silence cog.""" + +    @autospec(silence, "Scheduler", pass_mocks=False)      def setUp(self) -> None:          self.bot = MockBot() -        self.cog = Silence(self.bot) -        self.ctx = MockContext() -        self.cog._verified_role = None -        # Set event so command callbacks can continue. -        self.cog._get_instance_vars_event.set() +        self.cog = silence.Silence(self.bot) -    async def test_instance_vars_got_guild(self): +    @autospec(silence, "SilenceNotifier", pass_mocks=False) +    async def test_async_init_got_guild(self):          """Bot got guild after it became available.""" -        await self.cog._get_instance_vars() -        self.bot.wait_until_guild_available.assert_called_once() +        await self.cog._async_init() +        self.bot.wait_until_guild_available.assert_awaited_once()          self.bot.get_guild.assert_called_once_with(Guild.id) -    async def test_instance_vars_got_role(self): +    @autospec(silence, "SilenceNotifier", pass_mocks=False) +    async def test_async_init_got_role(self):          """Got `Roles.verified` role from guild.""" -        await self.cog._get_instance_vars()          guild = self.bot.get_guild() -        guild.get_role.assert_called_once_with(Roles.verified) +        guild.get_role.side_effect = lambda id_: Mock(id=id_) + +        await self.cog._async_init() +        self.assertEqual(self.cog._verified_role.id, Roles.verified) -    async def test_instance_vars_got_channels(self): +    @autospec(silence, "SilenceNotifier", pass_mocks=False) +    async def test_async_init_got_channels(self):          """Got channels from bot.""" -        await self.cog._get_instance_vars() -        self.bot.get_channel.called_once_with(Channels.mod_alerts) -        self.bot.get_channel.called_once_with(Channels.mod_log) +        self.bot.get_channel.side_effect = lambda id_: MockTextChannel(id=id_) + +        await self.cog._async_init() +        self.assertEqual(self.cog._mod_alerts_channel.id, Channels.mod_alerts) -    @mock.patch("bot.exts.moderation.silence.SilenceNotifier") -    async def test_instance_vars_got_notifier(self, notifier): +    @autospec(silence, "SilenceNotifier") +    async def test_async_init_got_notifier(self, notifier):          """Notifier was started with channel.""" -        mod_log = MockTextChannel() -        self.bot.get_channel.side_effect = (None, mod_log) -        await self.cog._get_instance_vars() -        notifier.assert_called_once_with(mod_log) -        self.bot.get_channel.side_effect = None - -    async def test_silence_sent_correct_discord_message(self): -        """Check if proper message was sent when called with duration in channel with previous state.""" +        self.bot.get_channel.side_effect = lambda id_: MockTextChannel(id=id_) + +        await self.cog._async_init() +        notifier.assert_called_once_with(MockTextChannel(id=Channels.mod_log)) +        self.assertEqual(self.cog.notifier, notifier.return_value) + +    @autospec(silence, "SilenceNotifier", pass_mocks=False) +    async def test_async_init_rescheduled(self): +        """`_reschedule_` coroutine was awaited.""" +        self.cog._reschedule = mock.create_autospec(self.cog._reschedule) +        await self.cog._async_init() +        self.cog._reschedule.assert_awaited_once_with() + +    def test_cog_unload_cancelled_tasks(self): +        """The init task was cancelled.""" +        self.cog._init_task = asyncio.Future() +        self.cog.cog_unload() + +        # It's too annoying to test cancel_all since it's a done callback and wrapped in a lambda. +        self.assertTrue(self.cog._init_task.cancelled()) + +    @autospec("discord.ext.commands", "has_any_role") +    @mock.patch.object(silence, "MODERATION_ROLES", new=(1, 2, 3)) +    async def test_cog_check(self, role_check): +        """Role check was called with `MODERATION_ROLES`""" +        ctx = MockContext() +        role_check.return_value.predicate = mock.AsyncMock() + +        await self.cog.cog_check(ctx) +        role_check.assert_called_once_with(*(1, 2, 3)) +        role_check.return_value.predicate.assert_awaited_once_with(ctx) + + +@autospec(silence.Silence, "previous_overwrites", "unsilence_timestamps", pass_mocks=False) +class RescheduleTests(unittest.IsolatedAsyncioTestCase): +    """Tests for the rescheduling of cached unsilences.""" + +    @autospec(silence, "Scheduler", "SilenceNotifier", pass_mocks=False) +    def setUp(self): +        self.bot = MockBot() +        self.cog = silence.Silence(self.bot) +        self.cog._unsilence_wrapper = mock.create_autospec(self.cog._unsilence_wrapper) + +        with mock.patch.object(self.cog, "_reschedule", autospec=True): +            asyncio.run(self.cog._async_init())  # Populate instance attributes. + +    async def test_skipped_missing_channel(self): +        """Did nothing because the channel couldn't be retrieved.""" +        self.cog.unsilence_timestamps.items.return_value = [(123, -1), (123, 1), (123, 10000000000)] +        self.bot.get_channel.return_value = None + +        await self.cog._reschedule() + +        self.cog.notifier.add_channel.assert_not_called() +        self.cog._unsilence_wrapper.assert_not_called() +        self.cog.scheduler.schedule_later.assert_not_called() + +    async def test_added_permanent_to_notifier(self): +        """Permanently silenced channels were added to the notifier.""" +        channels = [MockTextChannel(id=123), MockTextChannel(id=456)] +        self.bot.get_channel.side_effect = channels +        self.cog.unsilence_timestamps.items.return_value = [(123, -1), (456, -1)] + +        await self.cog._reschedule() + +        self.cog.notifier.add_channel.assert_any_call(channels[0]) +        self.cog.notifier.add_channel.assert_any_call(channels[1]) + +        self.cog._unsilence_wrapper.assert_not_called() +        self.cog.scheduler.schedule_later.assert_not_called() + +    async def test_unsilenced_expired(self): +        """Unsilenced expired silences.""" +        channels = [MockTextChannel(id=123), MockTextChannel(id=456)] +        self.bot.get_channel.side_effect = channels +        self.cog.unsilence_timestamps.items.return_value = [(123, 100), (456, 200)] + +        await self.cog._reschedule() + +        self.cog._unsilence_wrapper.assert_any_call(channels[0]) +        self.cog._unsilence_wrapper.assert_any_call(channels[1]) + +        self.cog.notifier.add_channel.assert_not_called() +        self.cog.scheduler.schedule_later.assert_not_called() + +    @mock.patch.object(silence, "datetime", new=PatchedDatetime) +    async def test_rescheduled_active(self): +        """Rescheduled active silences.""" +        channels = [MockTextChannel(id=123), MockTextChannel(id=456)] +        self.bot.get_channel.side_effect = channels +        self.cog.unsilence_timestamps.items.return_value = [(123, 2000), (456, 3000)] +        silence.datetime.now.return_value = datetime.fromtimestamp(1000, tz=timezone.utc) + +        self.cog._unsilence_wrapper = mock.MagicMock() +        unsilence_return = self.cog._unsilence_wrapper.return_value + +        await self.cog._reschedule() + +        # Yuck. +        calls = [mock.call(1000, 123, unsilence_return), mock.call(2000, 456, unsilence_return)] +        self.cog.scheduler.schedule_later.assert_has_calls(calls) + +        unsilence_calls = [mock.call(channel) for channel in channels] +        self.cog._unsilence_wrapper.assert_has_calls(unsilence_calls) + +        self.cog.notifier.add_channel.assert_not_called() + + +@autospec(silence.Silence, "previous_overwrites", "unsilence_timestamps", pass_mocks=False) +class SilenceTests(unittest.IsolatedAsyncioTestCase): +    """Tests for the silence command and its related helper methods.""" + +    @autospec(silence.Silence, "_reschedule", pass_mocks=False) +    @autospec(silence, "Scheduler", "SilenceNotifier", pass_mocks=False) +    def setUp(self) -> None: +        self.bot = MockBot() +        self.cog = silence.Silence(self.bot) +        self.cog._init_task = asyncio.Future() +        self.cog._init_task.set_result(None) + +        # Avoid unawaited coroutine warnings. +        self.cog.scheduler.schedule_later.side_effect = lambda delay, task_id, coro: coro.close() + +        asyncio.run(self.cog._async_init())  # Populate instance attributes. + +        self.channel = MockTextChannel() +        self.overwrite = PermissionOverwrite(stream=True, send_messages=True, add_reactions=False) +        self.channel.overwrites_for.return_value = self.overwrite + +    async def test_sent_correct_message(self): +        """Appropriate failure/success message was sent by the command."""          test_cases = ( -            (0.0001, f"{Emojis.check_mark} silenced current channel for 0.0001 minute(s).", True,), -            (None, f"{Emojis.check_mark} silenced current channel indefinitely.", True,), -            (5, f"{Emojis.cross_mark} current channel is already silenced.", False,), +            (0.0001, silence.MSG_SILENCE_SUCCESS.format(duration=0.0001), True,), +            (None, silence.MSG_SILENCE_PERMANENT, True,), +            (5, silence.MSG_SILENCE_FAIL, False,),          ) -        for duration, result_message, _silence_patch_return in test_cases: -            with self.subTest( -                silence_duration=duration, -                result_message=result_message, -                starting_unsilenced_state=_silence_patch_return -            ): -                with mock.patch.object(self.cog, "_silence", return_value=_silence_patch_return): -                    await self.cog.silence.callback(self.cog, self.ctx, duration) -                    self.ctx.send.assert_called_once_with(result_message) -            self.ctx.reset_mock() - -    async def test_unsilence_sent_correct_discord_message(self): -        """Check if proper message was sent when unsilencing channel.""" -        test_cases = ( -            (True, f"{Emojis.check_mark} unsilenced current channel."), -            (False, f"{Emojis.cross_mark} current channel was not silenced.") +        for duration, message, was_silenced in test_cases: +            ctx = MockContext() +            with mock.patch.object(self.cog, "_set_silence_overwrites", return_value=was_silenced): +                with self.subTest(was_silenced=was_silenced, message=message, duration=duration): +                    await self.cog.silence.callback(self.cog, ctx, duration) +                    ctx.send.assert_called_once_with(message) + +    async def test_skipped_already_silenced(self): +        """Permissions were not set and `False` was returned for an already silenced channel.""" +        subtests = ( +            (False, PermissionOverwrite(send_messages=False, add_reactions=False)), +            (True, PermissionOverwrite(send_messages=True, add_reactions=True)), +            (True, PermissionOverwrite(send_messages=False, add_reactions=False)),          ) -        for _unsilence_patch_return, result_message in test_cases: -            with self.subTest( -                starting_silenced_state=_unsilence_patch_return, -                result_message=result_message -            ): -                with mock.patch.object(self.cog, "_unsilence", return_value=_unsilence_patch_return): -                    await self.cog.unsilence.callback(self.cog, self.ctx) -                    self.ctx.send.assert_called_once_with(result_message) -            self.ctx.reset_mock() - -    async def test_silence_private_for_false(self): -        """Permissions are not set and `False` is returned in an already silenced channel.""" -        perm_overwrite = Mock(send_messages=False) -        channel = Mock(overwrites_for=Mock(return_value=perm_overwrite)) - -        self.assertFalse(await self.cog._silence(channel, True, None)) -        channel.set_permissions.assert_not_called() -    async def test_silence_private_silenced_channel(self): -        """Channel had `send_message` permissions revoked.""" -        channel = MockTextChannel() -        self.assertTrue(await self.cog._silence(channel, False, None)) -        channel.set_permissions.assert_called_once() -        self.assertFalse(channel.set_permissions.call_args.kwargs['send_messages']) +        for contains, overwrite in subtests: +            with self.subTest(contains=contains, overwrite=overwrite): +                self.cog.scheduler.__contains__.return_value = contains +                channel = MockTextChannel() +                channel.overwrites_for.return_value = overwrite + +                self.assertFalse(await self.cog._set_silence_overwrites(channel)) +                channel.set_permissions.assert_not_called() + +    async def test_silenced_channel(self): +        """Channel had `send_message` and `add_reactions` permissions revoked for verified role.""" +        self.assertTrue(await self.cog._set_silence_overwrites(self.channel)) +        self.assertFalse(self.overwrite.send_messages) +        self.assertFalse(self.overwrite.add_reactions) +        self.channel.set_permissions.assert_awaited_once_with( +            self.cog._verified_role, +            overwrite=self.overwrite +        ) -    async def test_silence_private_preserves_permissions(self): -        """Previous permissions were preserved when channel was silenced.""" -        channel = MockTextChannel() -        # Set up mock channel permission state. -        mock_permissions = PermissionOverwrite() -        mock_permissions_dict = dict(mock_permissions) -        channel.overwrites_for.return_value = mock_permissions -        await self.cog._silence(channel, False, None) -        new_permissions = channel.set_permissions.call_args.kwargs -        # Remove 'send_messages' key because it got changed in the method. -        del new_permissions['send_messages'] -        del mock_permissions_dict['send_messages'] -        self.assertDictEqual(mock_permissions_dict, new_permissions) - -    async def test_silence_private_notifier(self): -        """Channel should be added to notifier with `persistent` set to `True`, and the other way around.""" -        channel = MockTextChannel() -        with mock.patch.object(self.cog, "notifier", create=True): -            with self.subTest(persistent=True): -                await self.cog._silence(channel, True, None) -                self.cog.notifier.add_channel.assert_called_once() - -        with mock.patch.object(self.cog, "notifier", create=True): -            with self.subTest(persistent=False): -                await self.cog._silence(channel, False, None) -                self.cog.notifier.add_channel.assert_not_called() - -    async def test_silence_private_added_muted_channel(self): -        """Channel was added to `muted_channels` on silence.""" +    async def test_preserved_other_overwrites(self): +        """Channel's other unrelated overwrites were not changed.""" +        prev_overwrite_dict = dict(self.overwrite) +        await self.cog._set_silence_overwrites(self.channel) +        new_overwrite_dict = dict(self.overwrite) + +        # Remove 'send_messages' & 'add_reactions' keys because they were changed by the method. +        del prev_overwrite_dict['send_messages'] +        del prev_overwrite_dict['add_reactions'] +        del new_overwrite_dict['send_messages'] +        del new_overwrite_dict['add_reactions'] + +        self.assertDictEqual(prev_overwrite_dict, new_overwrite_dict) + +    async def test_temp_not_added_to_notifier(self): +        """Channel was not added to notifier if a duration was set for the silence.""" +        with mock.patch.object(self.cog, "_set_silence_overwrites", return_value=True): +            await self.cog.silence.callback(self.cog, MockContext(), 15) +            self.cog.notifier.add_channel.assert_not_called() + +    async def test_indefinite_added_to_notifier(self): +        """Channel was added to notifier if a duration was not set for the silence.""" +        with mock.patch.object(self.cog, "_set_silence_overwrites", return_value=True): +            await self.cog.silence.callback(self.cog, MockContext(), None) +            self.cog.notifier.add_channel.assert_called_once() + +    async def test_silenced_not_added_to_notifier(self): +        """Channel was not added to the notifier if it was already silenced.""" +        with mock.patch.object(self.cog, "_set_silence_overwrites", return_value=False): +            await self.cog.silence.callback(self.cog, MockContext(), 15) +            self.cog.notifier.add_channel.assert_not_called() + +    async def test_cached_previous_overwrites(self): +        """Channel's previous overwrites were cached.""" +        overwrite_json = '{"send_messages": true, "add_reactions": false}' +        await self.cog._set_silence_overwrites(self.channel) +        self.cog.previous_overwrites.set.assert_called_once_with(self.channel.id, overwrite_json) + +    @autospec(silence, "datetime") +    async def test_cached_unsilence_time(self, datetime_mock): +        """The UTC POSIX timestamp for the unsilence was cached.""" +        now_timestamp = 100 +        duration = 15 +        timestamp = now_timestamp + duration * 60 +        datetime_mock.now.return_value = datetime.fromtimestamp(now_timestamp, tz=timezone.utc) + +        ctx = MockContext(channel=self.channel) +        await self.cog.silence.callback(self.cog, ctx, duration) + +        self.cog.unsilence_timestamps.set.assert_awaited_once_with(ctx.channel.id, timestamp) +        datetime_mock.now.assert_called_once_with(tz=timezone.utc)  # Ensure it's using an aware dt. + +    async def test_cached_indefinite_time(self): +        """A value of -1 was cached for a permanent silence.""" +        ctx = MockContext(channel=self.channel) +        await self.cog.silence.callback(self.cog, ctx, None) +        self.cog.unsilence_timestamps.set.assert_awaited_once_with(ctx.channel.id, -1) + +    async def test_scheduled_task(self): +        """An unsilence task was scheduled.""" +        ctx = MockContext(channel=self.channel, invoke=mock.MagicMock()) + +        await self.cog.silence.callback(self.cog, ctx, 5) + +        args = (300, ctx.channel.id, ctx.invoke.return_value) +        self.cog.scheduler.schedule_later.assert_called_once_with(*args) +        ctx.invoke.assert_called_once_with(self.cog.unsilence) + +    async def test_permanent_not_scheduled(self): +        """A task was not scheduled for a permanent silence.""" +        ctx = MockContext(channel=self.channel) +        await self.cog.silence.callback(self.cog, ctx, None) +        self.cog.scheduler.schedule_later.assert_not_called() + + +@autospec(silence.Silence, "unsilence_timestamps", pass_mocks=False) +class UnsilenceTests(unittest.IsolatedAsyncioTestCase): +    """Tests for the unsilence command and its related helper methods.""" + +    @autospec(silence.Silence, "_reschedule", pass_mocks=False) +    @autospec(silence, "Scheduler", "SilenceNotifier", pass_mocks=False) +    def setUp(self) -> None: +        self.bot = MockBot(get_channel=lambda _: MockTextChannel()) +        self.cog = silence.Silence(self.bot) +        self.cog._init_task = asyncio.Future() +        self.cog._init_task.set_result(None) + +        overwrites_cache = mock.create_autospec(self.cog.previous_overwrites, spec_set=True) +        self.cog.previous_overwrites = overwrites_cache + +        asyncio.run(self.cog._async_init())  # Populate instance attributes. + +        self.cog.scheduler.__contains__.return_value = True +        overwrites_cache.get.return_value = '{"send_messages": true, "add_reactions": false}' +        self.channel = MockTextChannel() +        self.overwrite = PermissionOverwrite(stream=True, send_messages=False, add_reactions=False) +        self.channel.overwrites_for.return_value = self.overwrite + +    async def test_sent_correct_message(self): +        """Appropriate failure/success message was sent by the command.""" +        unsilenced_overwrite = PermissionOverwrite(send_messages=True, add_reactions=True) +        test_cases = ( +            (True, silence.MSG_UNSILENCE_SUCCESS, unsilenced_overwrite), +            (False, silence.MSG_UNSILENCE_FAIL, unsilenced_overwrite), +            (False, silence.MSG_UNSILENCE_MANUAL, self.overwrite), +            (False, silence.MSG_UNSILENCE_MANUAL, PermissionOverwrite(send_messages=False)), +            (False, silence.MSG_UNSILENCE_MANUAL, PermissionOverwrite(add_reactions=False)), +        ) +        for was_unsilenced, message, overwrite in test_cases: +            ctx = MockContext() +            with self.subTest(was_unsilenced=was_unsilenced, message=message, overwrite=overwrite): +                with mock.patch.object(self.cog, "_unsilence", return_value=was_unsilenced): +                    ctx.channel.overwrites_for.return_value = overwrite +                    await self.cog.unsilence.callback(self.cog, ctx) +                    ctx.channel.send.assert_called_once_with(message) + +    async def test_skipped_already_unsilenced(self): +        """Permissions were not set and `False` was returned for an already unsilenced channel.""" +        self.cog.scheduler.__contains__.return_value = False +        self.cog.previous_overwrites.get.return_value = None          channel = MockTextChannel() -        with mock.patch.object(self.cog, "muted_channels") as muted_channels: -            await self.cog._silence(channel, False, None) -        muted_channels.add.assert_called_once_with(channel) -    async def test_unsilence_private_for_false(self): -        """Permissions are not set and `False` is returned in an unsilenced channel.""" -        channel = Mock()          self.assertFalse(await self.cog._unsilence(channel))          channel.set_permissions.assert_not_called() -    @mock.patch.object(Silence, "notifier", create=True) -    async def test_unsilence_private_unsilenced_channel(self, _): -        """Channel had `send_message` permissions restored""" -        perm_overwrite = MagicMock(send_messages=False) -        channel = MockTextChannel(overwrites_for=Mock(return_value=perm_overwrite)) -        self.assertTrue(await self.cog._unsilence(channel)) -        channel.set_permissions.assert_called_once() -        self.assertIsNone(channel.set_permissions.call_args.kwargs['send_messages']) - -    @mock.patch.object(Silence, "notifier", create=True) -    async def test_unsilence_private_removed_notifier(self, notifier): -        """Channel was removed from `notifier` on unsilence.""" -        perm_overwrite = MagicMock(send_messages=False) -        channel = MockTextChannel(overwrites_for=Mock(return_value=perm_overwrite)) -        await self.cog._unsilence(channel) -        notifier.remove_channel.assert_called_once_with(channel) - -    @mock.patch.object(Silence, "notifier", create=True) -    async def test_unsilence_private_removed_muted_channel(self, _): -        """Channel was removed from `muted_channels` on unsilence.""" -        perm_overwrite = MagicMock(send_messages=False) -        channel = MockTextChannel(overwrites_for=Mock(return_value=perm_overwrite)) -        with mock.patch.object(self.cog, "muted_channels") as muted_channels: -            await self.cog._unsilence(channel) -        muted_channels.discard.assert_called_once_with(channel) - -    @mock.patch.object(Silence, "notifier", create=True) -    async def test_unsilence_private_preserves_permissions(self, _): -        """Previous permissions were preserved when channel was unsilenced.""" -        channel = MockTextChannel() -        # Set up mock channel permission state. -        mock_permissions = PermissionOverwrite(send_messages=False) -        mock_permissions_dict = dict(mock_permissions) -        channel.overwrites_for.return_value = mock_permissions -        await self.cog._unsilence(channel) -        new_permissions = channel.set_permissions.call_args.kwargs -        # Remove 'send_messages' key because it got changed in the method. -        del new_permissions['send_messages'] -        del mock_permissions_dict['send_messages'] -        self.assertDictEqual(mock_permissions_dict, new_permissions) - -    @mock.patch("bot.exts.moderation.silence.asyncio") -    @mock.patch.object(Silence, "_mod_alerts_channel", create=True) -    def test_cog_unload_starts_task(self, alert_channel, asyncio_mock): -        """Task for sending an alert was created with present `muted_channels`.""" -        with mock.patch.object(self.cog, "muted_channels"): -            self.cog.cog_unload() -            alert_channel.send.assert_called_once_with(f"<@&{Roles.moderators}> channels left silenced on cog unload: ") -            asyncio_mock.create_task.assert_called_once_with(alert_channel.send()) - -    @mock.patch("bot.exts.moderation.silence.asyncio") -    def test_cog_unload_skips_task_start(self, asyncio_mock): -        """No task created with no channels.""" -        self.cog.cog_unload() -        asyncio_mock.create_task.assert_not_called() - -    @mock.patch("bot.exts.moderation.silence.with_role_check") -    @mock.patch("bot.exts.moderation.silence.MODERATION_ROLES", new=(1, 2, 3)) -    def test_cog_check(self, role_check): -        """Role check is called with `MODERATION_ROLES`""" -        self.cog.cog_check(self.ctx) -        role_check.assert_called_once_with(self.ctx, *(1, 2, 3)) +    async def test_restored_overwrites(self): +        """Channel's `send_message` and `add_reactions` overwrites were restored.""" +        await self.cog._unsilence(self.channel) +        self.channel.set_permissions.assert_awaited_once_with( +            self.cog._verified_role, +            overwrite=self.overwrite, +        ) + +        # Recall that these values are determined by the fixture. +        self.assertTrue(self.overwrite.send_messages) +        self.assertFalse(self.overwrite.add_reactions) + +    async def test_cache_miss_used_default_overwrites(self): +        """Both overwrites were set to None due previous values not being found in the cache.""" +        self.cog.previous_overwrites.get.return_value = None + +        await self.cog._unsilence(self.channel) +        self.channel.set_permissions.assert_awaited_once_with( +            self.cog._verified_role, +            overwrite=self.overwrite, +        ) + +        self.assertIsNone(self.overwrite.send_messages) +        self.assertIsNone(self.overwrite.add_reactions) + +    async def test_cache_miss_sent_mod_alert(self): +        """A message was sent to the mod alerts channel.""" +        self.cog.previous_overwrites.get.return_value = None + +        await self.cog._unsilence(self.channel) +        self.cog._mod_alerts_channel.send.assert_awaited_once() + +    async def test_removed_notifier(self): +        """Channel was removed from `notifier`.""" +        await self.cog._unsilence(self.channel) +        self.cog.notifier.remove_channel.assert_called_once_with(self.channel) + +    async def test_deleted_cached_overwrite(self): +        """Channel was deleted from the overwrites cache.""" +        await self.cog._unsilence(self.channel) +        self.cog.previous_overwrites.delete.assert_awaited_once_with(self.channel.id) + +    async def test_deleted_cached_time(self): +        """Channel was deleted from the timestamp cache.""" +        await self.cog._unsilence(self.channel) +        self.cog.unsilence_timestamps.delete.assert_awaited_once_with(self.channel.id) + +    async def test_cancelled_task(self): +        """The scheduled unsilence task should be cancelled.""" +        await self.cog._unsilence(self.channel) +        self.cog.scheduler.cancel.assert_called_once_with(self.channel.id) + +    async def test_preserved_other_overwrites(self): +        """Channel's other unrelated overwrites were not changed, including cache misses.""" +        for overwrite_json in ('{"send_messages": true, "add_reactions": null}', None): +            with self.subTest(overwrite_json=overwrite_json): +                self.cog.previous_overwrites.get.return_value = overwrite_json + +                prev_overwrite_dict = dict(self.overwrite) +                await self.cog._unsilence(self.channel) +                new_overwrite_dict = dict(self.overwrite) + +                # Remove these keys because they were modified by the unsilence. +                del prev_overwrite_dict['send_messages'] +                del prev_overwrite_dict['add_reactions'] +                del new_overwrite_dict['send_messages'] +                del new_overwrite_dict['add_reactions'] + +                self.assertDictEqual(prev_overwrite_dict, new_overwrite_dict) diff --git a/tests/bot/exts/moderation/test_slowmode.py b/tests/bot/exts/moderation/test_slowmode.py index e90394ab9..dad751e0d 100644 --- a/tests/bot/exts/moderation/test_slowmode.py +++ b/tests/bot/exts/moderation/test_slowmode.py @@ -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.exts.moderation.slowmode.with_role_check") +    @mock.patch("bot.exts.moderation.slowmode.has_any_role")      @mock.patch("bot.exts.moderation.slowmode.MODERATION_ROLES", new=(1, 2, 3)) -    def test_cog_check(self, role_check): +    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/exts/utils/test_snekbox.py b/tests/bot/exts/utils/test_snekbox.py index c272a4756..6601fad2c 100644 --- a/tests/bot/exts/utils/test_snekbox.py +++ b/tests/bot/exts/utils/test_snekbox.py @@ -117,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'), @@ -154,7 +154,7 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase):          self.cog.send_eval = AsyncMock(return_value=response)          self.cog.continue_eval = AsyncMock(return_value=None) -        await self.cog.eval_command.callback(self.cog, ctx=ctx, code='MyAwesomeCode') +        await self.cog.eval_command(self.cog, ctx=ctx, code='MyAwesomeCode')          self.cog.prepare_input.assert_called_once_with('MyAwesomeCode')          self.cog.send_eval.assert_called_once_with(ctx, 'MyAwesomeFormattedCode')          self.cog.continue_eval.assert_called_once_with(ctx, response) @@ -168,7 +168,7 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase):          self.cog.continue_eval = AsyncMock()          self.cog.continue_eval.side_effect = ('MyAwesomeCode-2', None) -        await self.cog.eval_command.callback(self.cog, ctx=ctx, code='MyAwesomeCode') +        await self.cog.eval_command(self.cog, ctx=ctx, code='MyAwesomeCode')          self.cog.prepare_input.has_calls(call('MyAwesomeCode'), call('MyAwesomeCode-2'))          self.cog.send_eval.assert_called_with(ctx, 'MyAwesomeFormattedCode')          self.cog.continue_eval.assert_called_with(ctx, response) @@ -180,7 +180,7 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase):          ctx.author.mention = '@LemonLemonishBeard#0042'          ctx.send = AsyncMock()          self.cog.jobs = (42,) -        await self.cog.eval_command.callback(self.cog, ctx=ctx, code='MyAwesomeCode') +        await self.cog.eval_command(self.cog, ctx=ctx, code='MyAwesomeCode')          ctx.send.assert_called_once_with(              "@LemonLemonishBeard#0042 You've already got a job running - please wait for it to finish!"          ) @@ -188,8 +188,8 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase):      async def test_eval_command_call_help(self):          """Test if the eval command call the help command if no code is provided."""          ctx = MockContext(command="sentinel") -        await self.cog.eval_command.callback(self.cog, ctx=ctx, code='') -        ctx.send_help.assert_called_once_with("sentinel") +        await self.cog.eval_command(self.cog, ctx=ctx, code='') +        ctx.send_help.assert_called_once_with(ctx.command)      async def test_send_eval(self):          """Test the send_eval function.""" @@ -290,7 +290,7 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase):              )          )          ctx.message.add_reaction.assert_called_once_with(snekbox.REEVAL_EMOJI) -        ctx.message.clear_reactions.assert_called_once() +        ctx.message.clear_reaction.assert_called_once_with(snekbox.REEVAL_EMOJI)          response.delete.assert_called_once()      async def test_continue_eval_does_not_continue(self): @@ -299,7 +299,7 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase):          actual = await self.cog.continue_eval(ctx, MockMessage())          self.assertEqual(actual, None) -        ctx.message.clear_reactions.assert_called_once() +        ctx.message.clear_reaction.assert_called_once_with(snekbox.REEVAL_EMOJI)      async def test_get_code(self):          """Should return 1st arg (or None) if eval cmd in message, otherwise return full content.""" diff --git a/tests/bot/patches/__init__.py b/tests/bot/patches/__init__.py deleted file mode 100644 index e69de29bb..000000000 --- a/tests/bot/patches/__init__.py +++ /dev/null diff --git a/tests/bot/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/helpers.py b/tests/helpers.py index facc4e1af..870f66197 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -5,7 +5,7 @@ import itertools  import logging  import unittest.mock  from asyncio import AbstractEventLoop -from typing import Callable, Iterable, Optional +from typing import Iterable, Optional  import discord  from aiohttp import ClientSession @@ -14,6 +14,7 @@ from discord.ext.commands import Context  from bot.api import APIClient  from bot.async_stats import AsyncStatsClient  from bot.bot import Bot +from tests._autospec import autospec  # noqa: F401 other modules import it via this module  for logger in logging.Logger.manager.loggerDict.values(): @@ -26,24 +27,6 @@ for logger in logging.Logger.manager.loggerDict.values():      logger.setLevel(logging.CRITICAL) -def autospec(target, *attributes: str, **kwargs) -> Callable: -    """Patch multiple `attributes` of a `target` with autospecced mocks and `spec_set` as True.""" -    # Caller's kwargs should take priority and overwrite the defaults. -    kwargs = {'spec_set': True, 'autospec': True, **kwargs} - -    # Import the target if it's a string. -    # This is to support both object and string targets like patch.multiple. -    if type(target) is str: -        target = unittest.mock._importer(target) - -    def decorator(func): -        for attribute in attributes: -            patcher = unittest.mock.patch.object(target, attribute, **kwargs) -            func = patcher(func) -        return func -    return decorator - -  class HashableMixin(discord.mixins.EqualityComparable):      """      Mixin that provides similar hashing and equality functionality as discord.py's `Hashable` mixin. @@ -308,7 +291,11 @@ class MockBot(CustomMockMixin, unittest.mock.MagicMock):      Instances of this class will follow the specifications of `discord.ext.commands.Bot` instances.      For more information, see the `MockGuild` docstring.      """ -    spec_set = Bot(command_prefix=unittest.mock.MagicMock(), loop=_get_mock_loop()) +    spec_set = Bot( +        command_prefix=unittest.mock.MagicMock(), +        loop=_get_mock_loop(), +        redis_session=unittest.mock.MagicMock(), +    )      additional_spec_asyncs = ("wait_for", "redis_ready")      def __init__(self, **kwargs) -> None:  |