diff options
| author | 2019-09-18 14:00:57 -0700 | |
|---|---|---|
| committer | 2019-09-18 14:10:06 -0700 | |
| commit | 2ac0c6df978f20a488b3eb026753c8a972cb2554 (patch) | |
| tree | 43fc70b14b517139dea5324e762ba92a9e211e28 | |
| parent | Docstring linting chunk 7 (diff) | |
| parent | Merge pull request #436 from python-discord/enhance-offtopicnames-search (diff) | |
Merge branch 'master' into flake8-plugins
67 files changed, 1763 insertions, 807 deletions
| diff --git a/.gitignore b/.gitignore index be4f43c7f..cda3aeb9f 100644 --- a/.gitignore +++ b/.gitignore @@ -114,3 +114,6 @@ log.*  # Custom user configuration  config.yml + +# JUnit XML reports from pytest +junit.xml diff --git a/Dockerfile b/Dockerfile index 864b4e557..aa6333380 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,15 +14,7 @@ RUN apk add --no-cache \      zlib-dev  ENV \ -    LIBRARY_PATH=/lib:/usr/lib \ -    PIPENV_HIDE_EMOJIS=1 \ -    PIPENV_HIDE_EMOJIS=1 \ -    PIPENV_IGNORE_VIRTUALENVS=1 \ -    PIPENV_IGNORE_VIRTUALENVS=1 \ -    PIPENV_NOSPIN=1 \ -    PIPENV_NOSPIN=1 \ -    PIPENV_VENV_IN_PROJECT=1 \ -    PIPENV_VENV_IN_PROJECT=1 +    LIBRARY_PATH=/lib:/usr/lib  RUN pip install -U pipenv @@ -32,4 +24,4 @@ COPY . .  RUN pipenv install --deploy --system  ENTRYPOINT ["/sbin/tini", "--"] -CMD ["pipenv", "run", "start"] +CMD ["python3", "-m", "bot"] @@ -4,7 +4,7 @@ verify_ssl = true  name = "pypi"  [packages] -discord-py = {git = "https://github.com/Rapptz/discord.py.git",extras = ["voice"],ref = "860d6a9ace8248dfeec18b8b159e7b757d9f56bb",editable = true} +discord-py = "~=1.2"  aiodns = "*"  logmatic-python = "*"  aiohttp = "*" @@ -18,6 +18,7 @@ python-dateutil = "*"  deepdiff = "*"  requests = "*"  dateparser = "*" +more_itertools = "~=7.2"  urllib3 = ">=1.24.2,<1.25"  [dev-packages] @@ -33,6 +34,7 @@ pre-commit = "~=1.18"  safety = "*"  dodgy = "*"  pytest = "*" +pytest-cov = "*"  [requires]  python_version = "3.7" diff --git a/Pipfile.lock b/Pipfile.lock index e9b64e6cd..9bdcff923 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@  {      "_meta": {          "hash": { -            "sha256": "a953197b27bbc2af413a1ddd465bb95254167e3a0bba525a3ad34ac738dd6ae4" +            "sha256": "29aaaa90a070d544e5b39fb6033410daa9bb7f658077205e44099f3175f6822b"          },          "pipfile-spec": 6,          "requires": { @@ -18,11 +18,11 @@      "default": {          "aio-pika": {              "hashes": [ -                "sha256:31e08189841a8350db5bec70608b4d2fbacb89c0a555a18ec47511716a9bfc41", -                "sha256:adfe0acf34356ccd9654a9a1c46f7e8db1dc4497a774c0e54bf2d3af14571bd0" +                "sha256:29f27a8092169924c9eefb0c5e428d216706618dc9caa75ddb7759638e16cf26", +                "sha256:4f77ba9b6e7bc27fc88c49638bc3657ae5d4a2539e17fa0c2b25b370547b1b50"              ],              "index": "pypi", -            "version": "==6.1.1" +            "version": "==6.1.2"          },          "aiodns": {              "hashes": [ @@ -34,31 +34,31 @@          },          "aiohttp": {              "hashes": [ -                "sha256:0419705a36b43c0ac6f15469f9c2a08cad5c939d78bd12a5c23ea167c8253b2b", -                "sha256:1812fc4bc6ac1bde007daa05d2d0f61199324e0cc893b11523e646595047ca08", -                "sha256:2214b5c0153f45256d5d52d1e0cafe53f9905ed035a142191727a5fb620c03dd", -                "sha256:275909137f0c92c61ba6bb1af856a522d5546f1de8ea01e4e726321c697754ac", -                "sha256:3983611922b561868428ea1e7269e757803713f55b53502423decc509fef1650", -                "sha256:51afec6ffa50a9da4cdef188971a802beb1ca8e8edb40fa429e5e529db3475fa", -                "sha256:589f2ec8a101a0f340453ee6945bdfea8e1cd84c8d88e5be08716c34c0799d95", -                "sha256:789820ddc65e1f5e71516adaca2e9022498fa5a837c79ba9c692a9f8f916c330", -                "sha256:7a968a0bdaaf9abacc260911775611c9a602214a23aeb846f2eb2eeaa350c4dc", -                "sha256:7aeefbed253f59ea39e70c5848de42ed85cb941165357fc7e87ab5d8f1f9592b", -                "sha256:7b2eb55c66512405103485bd7d285a839d53e7fdc261ab20e5bcc51d7aaff5de", -                "sha256:87bc95d3d333bb689c8d755b4a9d7095a2356108002149523dfc8e607d5d32a4", -                "sha256:9d80e40db208e29168d3723d1440ecbb06054d349c5ece6a2c5a611490830dd7", -                "sha256:a1b442195c2a77d33e4dbee67c9877ccbdd3a1f686f91eb479a9577ed8cc326b", -                "sha256:ab3d769413b322d6092f169f316f7b21cd261a7589f7e31db779d5731b0480d8", -                "sha256:b066d3dec5d0f5aee6e34e5765095dc3d6d78ef9839640141a2b20816a0642bd", -                "sha256:b24e7845ae8de3e388ef4bcfcf7f96b05f52c8e633b33cf8003a6b1d726fc7c2", -                "sha256:c59a953c3f8524a7c86eaeaef5bf702555be12f5668f6384149fe4bb75c52698", -                "sha256:cf2cc6c2c10d242790412bea7ccf73726a9a44b4c4b073d2699ef3b48971fd95", -                "sha256:e0c9c8d4150ae904f308ff27b35446990d2b1dfc944702a21925937e937394c6", -                "sha256:f1839db4c2b08a9c8f9788112644f8a8557e8e0ecc77b07091afabb941dc55d0", -                "sha256:f3df52362be39908f9c028a65490fae0475e4898b43a03d8aa29d1e765b45e07" -            ], -            "index": "pypi", -            "version": "==3.4.4" +                "sha256:00d198585474299c9c3b4f1d5de1a576cc230d562abc5e4a0e81d71a20a6ca55", +                "sha256:0155af66de8c21b8dba4992aaeeabf55503caefae00067a3b1139f86d0ec50ed", +                "sha256:09654a9eca62d1bd6d64aa44db2498f60a5c1e0ac4750953fdd79d5c88955e10", +                "sha256:199f1d106e2b44b6dacdf6f9245493c7d716b01d0b7fbe1959318ba4dc64d1f5", +                "sha256:296f30dedc9f4b9e7a301e5cc963012264112d78a1d3094cd83ef148fdf33ca1", +                "sha256:368ed312550bd663ce84dc4b032a962fcb3c7cae099dbbd48663afc305e3b939", +                "sha256:40d7ea570b88db017c51392349cf99b7aefaaddd19d2c78368aeb0bddde9d390", +                "sha256:629102a193162e37102c50713e2e31dc9a2fe7ac5e481da83e5bb3c0cee700aa", +                "sha256:6d5ec9b8948c3d957e75ea14d41e9330e1ac3fed24ec53766c780f82805140dc", +                "sha256:87331d1d6810214085a50749160196391a712a13336cd02ce1c3ea3d05bcf8d5", +                "sha256:9a02a04bbe581c8605ac423ba3a74999ec9d8bce7ae37977a3d38680f5780b6d", +                "sha256:9c4c83f4fa1938377da32bc2d59379025ceeee8e24b89f72fcbccd8ca22dc9bf", +                "sha256:9cddaff94c0135ee627213ac6ca6d05724bfe6e7a356e5e09ec57bd3249510f6", +                "sha256:a25237abf327530d9561ef751eef9511ab56fd9431023ca6f4803f1994104d72", +                "sha256:a5cbd7157b0e383738b8e29d6e556fde8726823dae0e348952a61742b21aeb12", +                "sha256:a97a516e02b726e089cffcde2eea0d3258450389bbac48cbe89e0f0b6e7b0366", +                "sha256:acc89b29b5f4e2332d65cd1b7d10c609a75b88ef8925d487a611ca788432dfa4", +                "sha256:b05bd85cc99b06740aad3629c2585bda7b83bd86e080b44ba47faf905fdf1300", +                "sha256:c2bec436a2b5dafe5eaeb297c03711074d46b6eb236d002c13c42f25c4a8ce9d", +                "sha256:cc619d974c8c11fe84527e4b5e1c07238799a8c29ea1c1285149170524ba9303", +                "sha256:d4392defd4648badaa42b3e101080ae3313e8f4787cb517efd3f5b8157eaefd6", +                "sha256:e1c3c582ee11af7f63a34a46f0448fca58e59889396ffdae1f482085061a2889" +            ], +            "index": "pypi", +            "version": "==3.5.4"          },          "aiormq": {              "hashes": [ @@ -105,10 +105,10 @@          },          "certifi": {              "hashes": [ -                "sha256:046832c04d4e752f37383b628bc601a7ea7211496b4638f6514d0e5b9acc4939", -                "sha256:945e3ba63a0b9f577b1395204e13c3a231f9bc0223888be653286534e5873695" +                "sha256:e4f3620cfea4f83eedc95b24abd9cd56f3c4b146dd0177e83a21b4eb49e21e50", +                "sha256:fd7c7c74727ddcf00e9acd26bba8da604ffec95bf1c2144e67aff7a8b50e6cef"              ], -            "version": "==2019.6.16" +            "version": "==2019.9.11"          },          "cffi": {              "hashes": [ @@ -152,11 +152,11 @@          },          "dateparser": {              "hashes": [ -                "sha256:42d51be54e74a8e80a4d76d1fa6e4edd997098fce24ad2d94a2eab5ef247193e", -                "sha256:78124c458c461ea7198faa3c038f6381f37588b84bb42740e91a4cbd260b1d09" +                "sha256:983d84b5e3861cb0aa240cad07f12899bb10b62328aae188b9007e04ce37d665", +                "sha256:e1eac8ef28de69a554d5fcdb60b172d526d61924b1a40afbbb08df459a36006b"              ],              "index": "pypi", -            "version": "==0.7.1" +            "version": "==0.7.2"          },          "deepdiff": {              "hashes": [ @@ -167,12 +167,11 @@              "version": "==4.0.7"          },          "discord-py": { -            "editable": true, -            "extras": [ -                "voice" +            "hashes": [ +                "sha256:4684733fa137cc7def18087ae935af615212e423e3dbbe3e84ef01d7ae8ed17d"              ], -            "git": "https://github.com/Rapptz/discord.py.git", -            "ref": "860d6a9ace8248dfeec18b8b159e7b757d9f56bb" +            "index": "pypi", +            "version": "==1.2.3"          },          "docutils": {              "hashes": [ @@ -293,6 +292,14 @@              ],              "version": "==1.1.1"          }, +        "more-itertools": { +            "hashes": [ +                "sha256:409cd48d4db7052af495b09dec721011634af3753ae1ef92d2b32f73a745f832", +                "sha256:92b8c4b06dac4f0611c0729b2f2ede52b2e1bac1ab48f089c7ddc12e26bb60c4" +            ], +            "index": "pypi", +            "version": "==7.2.0" +        },          "multidict": {              "hashes": [                  "sha256:024b8129695a952ebd93373e45b5d341dbb87c17ce49637b34000093f243dd4f", @@ -378,42 +385,6 @@              ],              "version": "==2.4.2"          }, -        "pynacl": { -            "hashes": [ -                "sha256:04e30e5bdeeb2d5b34107f28cd2f5bbfdc6c616f3be88fc6f53582ff1669eeca", -                "sha256:0bfa0d94d2be6874e40f896e0a67e290749151e7de767c5aefbad1121cad7512", -                "sha256:11aa4e141b2456ce5cecc19c130e970793fa3a2c2e6fbb8ad65b28f35aa9e6b6", -                "sha256:13bdc1fe084ff9ac7653ae5a924cae03bf4bb07c6667c9eb5b6eb3c570220776", -                "sha256:14339dc233e7a9dda80a3800e64e7ff89d0878ba23360eea24f1af1b13772cac", -                "sha256:1d33e775fab3f383167afb20b9927aaf4961b953d76eeb271a5703a6d756b65b", -                "sha256:2a42b2399d0428619e58dac7734838102d35f6dcdee149e0088823629bf99fbb", -                "sha256:2dce05ac8b3c37b9e2f65eab56c544885607394753e9613fd159d5e2045c2d98", -                "sha256:63cfccdc6217edcaa48369191ae4dca0c390af3c74f23c619e954973035948cd", -                "sha256:6453b0dae593163ffc6db6f9c9c1597d35c650598e2c39c0590d1757207a1ac2", -                "sha256:73a5a96fb5fbf2215beee2353a128d382dbca83f5341f0d3c750877a236569ef", -                "sha256:8abb4ef79161a5f58848b30ab6fb98d8c466da21fdd65558ce1d7afc02c70b5f", -                "sha256:8ac1167195b32a8755de06efd5b2d2fe76fc864517dab66aaf65662cc59e1988", -                "sha256:8f505f42f659012794414fa57c498404e64db78f1d98dfd40e318c569f3c783b", -                "sha256:9c8a06556918ee8e3ab48c65574f318f5a0a4d31437fc135da7ee9d4f9080415", -                "sha256:a1e25fc5650cf64f01c9e435033e53a4aca9de30eb9929d099f3bb078e18f8f2", -                "sha256:be71cd5fce04061e1f3d39597f93619c80cdd3558a6c9ba99a546f144a8d8101", -                "sha256:c5b1a7a680218dee9da0f1b5e24072c46b3c275d35712bc1d505b85bb03441c0", -                "sha256:cb785db1a9468841a1265c9215c60fe5d7af2fb1b209e3316a152704607fc582", -                "sha256:cf6877124ae6a0698404e169b3ba534542cfbc43f939d46b927d956daf0a373a", -                "sha256:d0eb5b2795b7ee2cbcfcadacbe95a13afbda048a262bd369da9904fecb568975", -                "sha256:d3a934e2b9f20abac009d5b6951067cfb5486889cb913192b4d8288b216842f1", -                "sha256:d795f506bcc9463efb5ebb0f65ed77921dcc9e0a50499dedd89f208445de9ecb", -                "sha256:d8aaf7e5d6b0e0ef7d6dbf7abeb75085713d0100b4eb1a4e4e857de76d77ac45", -                "sha256:de2aaca8386cf4d70f1796352f2346f48ddb0bed61dc43a3ce773ba12e064031", -                "sha256:e0d38fa0a75f65f556fb912f2c6790d1fa29b7dd27a1d9cc5591b281321eaaa9", -                "sha256:eb2acabbd487a46b38540a819ef67e477a674481f84a82a7ba2234b9ba46f752", -                "sha256:eeee629828d0eb4f6d98ac41e9a3a6461d114d1d0aa111a8931c049359298da0", -                "sha256:f5836463a3c0cca300295b229b6c7003c415a9d11f8f9288ddbd728e2746524c", -                "sha256:f5ce9e26d25eb0b2d96f3ef0ad70e1d3ae89b5d60255c462252a3e456a48c053", -                "sha256:fabf73d5d0286f9e078774f3435601d2735c94ce9e514ac4fb945701edead7e4" -            ], -            "version": "==1.2.1" -        },          "pyparsing": {              "hashes": [                  "sha256:6f98a7b9397e206d78cc01df10131398f1c8b8510a2f4d97d9abd82e1aacdd80", @@ -637,10 +608,10 @@          },          "certifi": {              "hashes": [ -                "sha256:046832c04d4e752f37383b628bc601a7ea7211496b4638f6514d0e5b9acc4939", -                "sha256:945e3ba63a0b9f577b1395204e13c3a231f9bc0223888be653286534e5873695" +                "sha256:e4f3620cfea4f83eedc95b24abd9cd56f3c4b146dd0177e83a21b4eb49e21e50", +                "sha256:fd7c7c74727ddcf00e9acd26bba8da604ffec95bf1c2144e67aff7a8b50e6cef"              ], -            "version": "==2019.6.16" +            "version": "==2019.9.11"          },          "cfgv": {              "hashes": [ @@ -663,6 +634,43 @@              ],              "version": "==7.0"          }, +        "coverage": { +            "hashes": [ +                "sha256:08907593569fe59baca0bf152c43f3863201efb6113ecb38ce7e97ce339805a6", +                "sha256:0be0f1ed45fc0c185cfd4ecc19a1d6532d72f86a2bac9de7e24541febad72650", +                "sha256:141f08ed3c4b1847015e2cd62ec06d35e67a3ac185c26f7635f4406b90afa9c5", +                "sha256:19e4df788a0581238e9390c85a7a09af39c7b539b29f25c89209e6c3e371270d", +                "sha256:23cc09ed395b03424d1ae30dcc292615c1372bfba7141eb85e11e50efaa6b351", +                "sha256:245388cda02af78276b479f299bbf3783ef0a6a6273037d7c60dc73b8d8d7755", +                "sha256:331cb5115673a20fb131dadd22f5bcaf7677ef758741312bee4937d71a14b2ef", +                "sha256:386e2e4090f0bc5df274e720105c342263423e77ee8826002dcffe0c9533dbca", +                "sha256:3a794ce50daee01c74a494919d5ebdc23d58873747fa0e288318728533a3e1ca", +                "sha256:60851187677b24c6085248f0a0b9b98d49cba7ecc7ec60ba6b9d2e5574ac1ee9", +                "sha256:63a9a5fc43b58735f65ed63d2cf43508f462dc49857da70b8980ad78d41d52fc", +                "sha256:6b62544bb68106e3f00b21c8930e83e584fdca005d4fffd29bb39fb3ffa03cb5", +                "sha256:6ba744056423ef8d450cf627289166da65903885272055fb4b5e113137cfa14f", +                "sha256:7494b0b0274c5072bddbfd5b4a6c6f18fbbe1ab1d22a41e99cd2d00c8f96ecfe", +                "sha256:826f32b9547c8091679ff292a82aca9c7b9650f9fda3e2ca6bf2ac905b7ce888", +                "sha256:93715dffbcd0678057f947f496484e906bf9509f5c1c38fc9ba3922893cda5f5", +                "sha256:9a334d6c83dfeadae576b4d633a71620d40d1c379129d587faa42ee3e2a85cce", +                "sha256:af7ed8a8aa6957aac47b4268631fa1df984643f07ef00acd374e456364b373f5", +                "sha256:bf0a7aed7f5521c7ca67febd57db473af4762b9622254291fbcbb8cd0ba5e33e", +                "sha256:bf1ef9eb901113a9805287e090452c05547578eaab1b62e4ad456fcc049a9b7e", +                "sha256:c0afd27bc0e307a1ffc04ca5ec010a290e49e3afbe841c5cafc5c5a80ecd81c9", +                "sha256:dd579709a87092c6dbee09d1b7cfa81831040705ffa12a1b248935274aee0437", +                "sha256:df6712284b2e44a065097846488f66840445eb987eb81b3cc6e4149e7b6982e1", +                "sha256:e07d9f1a23e9e93ab5c62902833bf3e4b1f65502927379148b6622686223125c", +                "sha256:e2ede7c1d45e65e209d6093b762e98e8318ddeff95317d07a27a2140b80cfd24", +                "sha256:e4ef9c164eb55123c62411f5936b5c2e521b12356037b6e1c2617cef45523d47", +                "sha256:eca2b7343524e7ba246cab8ff00cab47a2d6d54ada3b02772e908a45675722e2", +                "sha256:eee64c616adeff7db37cc37da4180a3a5b6177f5c46b187894e633f088fb5b28", +                "sha256:ef824cad1f980d27f26166f86856efe11eff9912c4fed97d3804820d43fa550c", +                "sha256:efc89291bd5a08855829a3c522df16d856455297cf35ae827a37edac45f466a7", +                "sha256:fa964bae817babece5aa2e8c1af841bebb6d0b9add8e637548809d040443fee0", +                "sha256:ff37757e068ae606659c28c3bd0d923f9d29a85de79bf25b2b34b148473b5025" +            ], +            "version": "==4.5.4" +        },          "dodgy": {              "hashes": [                  "sha256:65e13cf878d7aff129f1461c13cb5fd1bb6dfe66bb5327e09379c3877763280c" @@ -763,11 +771,11 @@          },          "importlib-metadata": {              "hashes": [ -                "sha256:9ff1b1c5a354142de080b8a4e9803e5d0d59283c93aed808617c787d16768375", -                "sha256:b7143592e374e50584564794fcb8aaf00a23025f9db866627f89a21491847a8d" +                "sha256:aa18d7378b00b40847790e7c27e11673d7fed219354109d0e7b9e5b25dc3ad26", +                "sha256:d5f18a79777f3aa179c145737780282e27b508fc8fd688cb17c7a813e8bd39af"              ],              "markers": "python_version < '3.8'", -            "version": "==0.20" +            "version": "==0.23"          },          "mccabe": {              "hashes": [ @@ -781,6 +789,7 @@                  "sha256:409cd48d4db7052af495b09dec721011634af3753ae1ef92d2b32f73a745f832",                  "sha256:92b8c4b06dac4f0611c0729b2f2ede52b2e1bac1ab48f089c7ddc12e26bb60c4"              ], +            "index": "pypi",              "version": "==7.2.0"          },          "nodeenv": { @@ -798,10 +807,10 @@          },          "pluggy": {              "hashes": [ -                "sha256:0825a152ac059776623854c1543d65a4ad408eb3d33ee114dff91e57ec6ae6fc", -                "sha256:b9817417e95936bf75d85d3f8767f7df6cdde751fc40aed3bb3074cbcb77757c" +                "sha256:0db4b7601aae1d35b4a033282da476845aa19185c1e6964b25cf324b5e4ec3e6", +                "sha256:fa5fa1622fa6dd5c030e9cad086fa19ef6a0cf6d7a2d12318e10cb49d6d68f34"              ], -            "version": "==0.12.0" +            "version": "==0.13.0"          },          "pre-commit": {              "hashes": [ @@ -854,6 +863,14 @@              "index": "pypi",              "version": "==5.1.2"          }, +        "pytest-cov": { +            "hashes": [ +                "sha256:2b097cde81a302e1047331b48cadacf23577e431b61e9c6f49a1170bbe3d3da6", +                "sha256:e00ea4fdde970725482f1f35630d12f074e121a23801aabf2ae154ec6bdd343a" +            ], +            "index": "pypi", +            "version": "==2.7.1" +        },          "pyyaml": {              "hashes": [                  "sha256:0113bc0ec2ad727182326b61326afa3d1d8280ae1122493553fd6f4397f33df9", diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 19df35c11..4dcad685c 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -38,9 +38,23 @@ jobs:    - script: python -m flake8      displayName: 'Run linter' -  - script: BOT_TOKEN=foobar python -m pytest tests +  - script: BOT_API_KEY=foo BOT_TOKEN=bar WOLFRAM_API_KEY=baz python -m pytest --junitxml=junit.xml --cov=bot --cov-branch --cov-report=term --cov-report=xml tests      displayName: Run tests +  - task: PublishCodeCoverageResults@1 +    displayName: 'Publish Coverage Results' +    condition: succeededOrFailed() +    inputs: +      codeCoverageTool: Cobertura +      summaryFileLocation: coverage.xml + +  - task: PublishTestResults@2 +    displayName: 'Publish Test Results' +    condition: succeededOrFailed() +    inputs: +      testResultsFiles: junit.xml +      testRunTitle: 'Bot Test results' +  - job: build    displayName: 'Build Containers'    dependsOn: 'test' diff --git a/bot/__main__.py b/bot/__main__.py index e12508e6d..f25693734 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -2,10 +2,11 @@ import asyncio  import logging  import socket +import discord  from aiohttp import AsyncResolver, ClientSession, TCPConnector -from discord import Game  from discord.ext.commands import Bot, when_mentioned_or +from bot import patches  from bot.api import APIClient, APILoggingHandler  from bot.constants import Bot as BotConfig, DEBUG_MODE @@ -14,9 +15,9 @@ log = logging.getLogger('bot')  bot = Bot(      command_prefix=when_mentioned_or(BotConfig.prefix), -    activity=Game(name="Commands: !help"), +    activity=discord.Game(name="Commands: !help"),      case_insensitive=True, -    max_messages=10_000 +    max_messages=10_000,  )  # Global aiohttp session for all cogs @@ -53,7 +54,6 @@ if not DEBUG_MODE:  # Feature cogs  bot.load_extension("bot.cogs.alias")  bot.load_extension("bot.cogs.defcon") -bot.load_extension("bot.cogs.deployment")  bot.load_extension("bot.cogs.eval")  bot.load_extension("bot.cogs.free")  bot.load_extension("bot.cogs.information") @@ -72,6 +72,11 @@ bot.load_extension("bot.cogs.utils")  bot.load_extension("bot.cogs.watchchannels")  bot.load_extension("bot.cogs.wolfram") +# 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(BotConfig.token) -bot.http_session.close()  # Close the aiohttp session when the bot finishes running +# This calls a coroutine, so it doesn't do anything at the moment. +# bot.http_session.close()  # Close the aiohttp session when the bot finishes running diff --git a/bot/api.py b/bot/api.py index b714bda24..5ab554052 100644 --- a/bot/api.py +++ b/bot/api.py @@ -1,6 +1,6 @@  import asyncio  import logging -from typing import Union +from typing import Optional  from urllib.parse import quote as quote_url  import aiohttp @@ -11,11 +11,23 @@ log = logging.getLogger(__name__)  class ResponseCodeError(ValueError): -    """Represent a not-OK response code.""" - -    def __init__(self, response: aiohttp.ClientResponse): +    """Exception representing a non-OK response code.""" + +    def __init__( +        self, +        response: aiohttp.ClientResponse, +        response_json: Optional[dict] = None, +        response_text: str = "" +    ): +        self.status = response.status +        self.response_json = response_json or {} +        self.response_text = response_text          self.response = response +    def __str__(self): +        response = self.response_json if self.response_json else self.response_text +        return f"Status: {self.status} Response: {response}" +  class APIClient:      """Django Site API wrapper.""" @@ -36,42 +48,47 @@ class APIClient:      def _url_for(endpoint: str) -> str:          return f"{URLs.site_schema}{URLs.site_api}/{quote_url(endpoint)}" -    def maybe_raise_for_status(self, response: aiohttp.ClientResponse, should_raise: bool) -> None: +    async def maybe_raise_for_status(self, response: aiohttp.ClientResponse, should_raise: bool) -> None:          """Raise ResponseCodeError for non-OK response if an exception should be raised."""          if should_raise and response.status >= 400: -            raise ResponseCodeError(response=response) +            try: +                response_json = await response.json() +                raise ResponseCodeError(response=response, response_json=response_json) +            except aiohttp.ContentTypeError: +                response_text = await response.text() +                raise ResponseCodeError(response=response, response_text=response_text)      async def get(self, endpoint: str, *args, raise_for_status: bool = True, **kwargs) -> dict:          """Site API GET."""          async with self.session.get(self._url_for(endpoint), *args, **kwargs) as resp: -            self.maybe_raise_for_status(resp, raise_for_status) +            await self.maybe_raise_for_status(resp, raise_for_status)              return await resp.json()      async def patch(self, endpoint: str, *args, raise_for_status: bool = True, **kwargs) -> dict:          """Site API PATCH."""          async with self.session.patch(self._url_for(endpoint), *args, **kwargs) as resp: -            self.maybe_raise_for_status(resp, raise_for_status) +            await self.maybe_raise_for_status(resp, raise_for_status)              return await resp.json()      async def post(self, endpoint: str, *args, raise_for_status: bool = True, **kwargs) -> dict:          """Site API POST."""          async with self.session.post(self._url_for(endpoint), *args, **kwargs) as resp: -            self.maybe_raise_for_status(resp, raise_for_status) +            await self.maybe_raise_for_status(resp, raise_for_status)              return await resp.json()      async def put(self, endpoint: str, *args, raise_for_status: bool = True, **kwargs) -> dict:          """Site API PUT."""          async with self.session.put(self._url_for(endpoint), *args, **kwargs) as resp: -            self.maybe_raise_for_status(resp, raise_for_status) +            await self.maybe_raise_for_status(resp, raise_for_status)              return await resp.json() -    async def delete(self, endpoint: str, *args, raise_for_status: bool = True, **kwargs) -> Union[dict, None]: +    async def delete(self, endpoint: str, *args, raise_for_status: bool = True, **kwargs) -> Optional[dict]:          """Site API DELETE."""          async with self.session.delete(self._url_for(endpoint), *args, **kwargs) as resp:              if resp.status == 204:                  return None -            self.maybe_raise_for_status(resp, raise_for_status) +            await self.maybe_raise_for_status(resp, raise_for_status)              return await resp.json() @@ -133,7 +150,7 @@ class APILoggingHandler(logging.StreamHandler):                  # 1. Do not log anything below `DEBUG`. This is only applicable                  #    for the monkeypatched `TRACE` logging level, which has a                  #    lower numeric value than `DEBUG`. -                record.levelno > logging.DEBUG +                record.levelno >= logging.DEBUG                  # 2. Ignore logging messages which are sent by this logging                  #    handler itself. This is required because if we were to                  #    not ignore messages emitted by this handler, we would diff --git a/bot/cogs/alias.py b/bot/cogs/alias.py index a01a05715..80ff37983 100644 --- a/bot/cogs/alias.py +++ b/bot/cogs/alias.py @@ -3,9 +3,7 @@ import logging  from typing import Union  from discord import Colour, Embed, Member, User -from discord.ext.commands import ( -    Bot, Command, Context, clean_content, command, group -) +from discord.ext.commands import Bot, Cog, Command, Context, clean_content, command, group  from bot.cogs.watchchannels.watchchannel import proxy_user  from bot.converters import TagNameConverter @@ -14,7 +12,7 @@ from bot.pagination import LinePaginator  log = logging.getLogger(__name__) -class Alias: +class Alias (Cog):      """Aliases for commonly used commands."""      def __init__(self, bot: Bot): @@ -98,7 +96,7 @@ class Alias:      @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 traceback") +        await self.invoke(ctx, "tags get", tag_name="traceback")      @group(name="get",             aliases=("show", "g"), @@ -112,8 +110,12 @@ class Alias:      async def tags_get_alias(              self, ctx: Context, *, tag_name: TagNameConverter = None      ) -> None: -        """Alias for invoking <prefix>tags get [tag_name].""" -        await self.invoke(ctx, "tags get", tag_name) +        """ +        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( @@ -132,6 +134,11 @@ class Alias:          """Alias for invoking <prefix>nomination end [user] [reason]."""          await self.invoke(ctx, "nomination end", user, reason=reason) +    @command(name="nominees", hidden=True) +    async def nominees_alias(self, ctx: Context) -> None: +        """Alias for invoking <prefix>tp watched.""" +        await self.invoke(ctx, "talentpool watched") +  def setup(bot: Bot) -> None:      """Alias cog load.""" diff --git a/bot/cogs/antispam.py b/bot/cogs/antispam.py index c7f6503a8..7a3360436 100644 --- a/bot/cogs/antispam.py +++ b/bot/cogs/antispam.py @@ -1,19 +1,23 @@ +import asyncio  import logging +from collections.abc import Mapping +from dataclasses import dataclass, field  from datetime import datetime, timedelta -from typing import List +from operator import itemgetter +from typing import Dict, Iterable, List, Set -from discord import Colour, Member, Message, Object, TextChannel -from discord.ext.commands import Bot +from discord import Colour, Member, Message, NotFound, Object, TextChannel +from discord.ext.commands import Bot, Cog  from bot import rules -from bot.cogs.moderation import Moderation  from bot.cogs.modlog import ModLog  from bot.constants import (      AntiSpam as AntiSpamConfig, Channels,      Colours, DEBUG_MODE, Event, Filter,      Guild as GuildConfig, Icons, -    Roles, STAFF_ROLES, +    STAFF_ROLES,  ) +from bot.converters import ExpirationDate  log = logging.getLogger(__name__) @@ -32,25 +36,104 @@ RULE_FUNCTION_MAPPING = {  } -class AntiSpam: -    """Spam detection & mitigation measures.""" +@dataclass +class DeletionContext: +    """Represents a Deletion Context for a single spam event.""" -    def __init__(self, bot: Bot): +    channel: TextChannel +    members: Dict[int, Member] = field(default_factory=dict) +    rules: Set[str] = field(default_factory=set) +    messages: Dict[int, Message] = field(default_factory=dict) + +    def add(self, rule_name: str, members: Iterable[Member], messages: Iterable[Message]) -> None: +        """Adds new rule violation events to the deletion context.""" +        self.rules.add(rule_name) + +        for member in members: +            if member.id not in self.members: +                self.members[member.id] = member + +        for message in messages: +            if message.id not in self.messages: +                self.messages[message.id] = message + +    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.display_name}#{m.discriminator} (`{m.id}`)" for m in self.members.values()) + +        mod_alert_message = ( +            f"**Triggered by:** {triggered_by_users}\n" +            f"**Channel:** {self.channel.mention}\n" +            f"**Rules:** {', '.join(rule for rule in self.rules)}\n" +        ) + +        # For multiple messages or those with excessive newlines, use the logs API +        if len(self.messages) > 1 or 'newlines' in self.rules: +            url = await modlog.upload_log(self.messages.values(), actor_id) +            mod_alert_message += f"A complete log of the offending messages can be found [here]({url})" +        else: +            mod_alert_message += "Message:\n" +            [message] = self.messages.values() +            content = message.clean_content +            remaining_chars = 2040 - len(mod_alert_message) + +            if len(content) > remaining_chars: +                content = content[:remaining_chars] + "..." + +            mod_alert_message += f"{content}" + +        *_, last_message = self.messages.values() +        await modlog.send_log_message( +            icon_url=Icons.filtering, +            colour=Colour(Colours.soft_red), +            title=f"Spam detected!", +            text=mod_alert_message, +            thumbnail=last_message.author.avatar_url_as(static_format="png"), +            channel_id=Channels.mod_alerts, +            ping_everyone=AntiSpamConfig.ping_everyone +        ) + + +class AntiSpam(Cog): +    """Cog that controls our anti-spam measures.""" + +    def __init__(self, bot: Bot, validation_errors: bool) -> None:          self.bot = bot -        self._muted_role = Object(Roles.muted) +        self.validation_errors = validation_errors +        role_id = AntiSpamConfig.punishment['role_id'] +        self.muted_role = Object(role_id) +        self.expiration_date_converter = ExpirationDate() + +        self.message_deletion_queue = dict() +        self.queue_consumption_tasks = dict()      @property      def mod_log(self) -> ModLog: -        """Get currently loaded ModLog cog instance.""" +        """Allows for easy access of the ModLog cog."""          return self.bot.get_cog("ModLog") +    @Cog.listener()      async def on_ready(self) -> None: -        """Instantiate punishment role.""" -        role_id = AntiSpamConfig.punishment['role_id'] -        self.muted_role = Object(role_id) +        """Unloads the cog and alerts admins if configuration validation failed.""" +        if self.validation_errors: +            body = "**The following errors were encountered:**\n" +            body += "\n".join(f"- {error}" for error in self.validation_errors.values()) +            body += "\n\n**The cog has been unloaded.**" + +            await self.mod_log.send_log_message( +                title=f"Error: AntiSpam configuration validation failed!", +                text=body, +                ping_everyone=True, +                icon_url=Icons.token_removed, +                colour=Colour.red() +            ) + +            self.bot.remove_cog(self.__class__.__name__) +            return +    @Cog.listener()      async def on_message(self, message: Message) -> None: -        """Monitor incoming messages & match against spam criteria for possible filtering.""" +        """Applies the antispam rules to each received message."""          if (              not message.guild              or message.guild.id != GuildConfig.id @@ -63,14 +146,15 @@ class AntiSpam:          # Fetch the rule configuration with the highest rule interval.          max_interval_config = max(              AntiSpamConfig.rules.values(), -            key=lambda config: config['interval'] +            key=itemgetter('interval')          )          max_interval = max_interval_config['interval']          # Store history messages since `interval` seconds ago in a list to prevent unnecessary API calls.          earliest_relevant_at = datetime.utcnow() - timedelta(seconds=max_interval)          relevant_messages = [ -            msg async for msg in message.channel.history(after=earliest_relevant_at, reverse=False) +            msg async for msg in message.channel.history(after=earliest_relevant_at, oldest_first=False) +            if not msg.author.bot          ]          for rule_name in AntiSpamConfig.rules: @@ -91,62 +175,53 @@ class AntiSpam:              if result is not None:                  reason, members, relevant_messages = result                  full_reason = f"`{rule_name}` rule: {reason}" + +                # If there's no spam event going on for this channel, start a new Message Deletion Context +                if message.channel.id not in self.message_deletion_queue: +                    log.trace(f"Creating queue for channel `{message.channel.id}`") +                    self.message_deletion_queue[message.channel.id] = DeletionContext(channel=message.channel) +                    self.queue_consumption_tasks = self.bot.loop.create_task( +                        self._process_deletion_context(message.channel.id) +                    ) + +                # Add the relevant of this trigger to the Deletion Context +                self.message_deletion_queue[message.channel.id].add( +                    rule_name=rule_name, +                    members=members, +                    messages=relevant_messages +                ) +                  for member in members:                      # Fire it off as a background task to ensure                      # that the sleep doesn't block further tasks                      self.bot.loop.create_task( -                        self.punish(message, member, full_reason, relevant_messages, rule_name) +                        self.punish(message, member, full_reason)                      )                  await self.maybe_delete_messages(message.channel, relevant_messages)                  break -    async def punish(self, msg: Message, member: Member, reason: str, messages: List[Message], rule_name: str) -> None: -        """Deal out punishment for author(s) of messages that meet our spam criteria.""" -        # Sanity check to ensure we're not lagging behind -        if self.muted_role not in member.roles: +    async def punish(self, msg: Message, member: Member, reason: str) -> None: +        """Punishes the given member for triggering an antispam rule.""" +        if not any(role.id == self.muted_role.id for role in member.roles):              remove_role_after = AntiSpamConfig.punishment['remove_after'] -            mod_alert_message = ( -                f"**Triggered by:** {member.display_name}#{member.discriminator} (`{member.id}`)\n" -                f"**Channel:** {msg.channel.mention}\n" -                f"**Reason:** {reason}\n" -            ) +            # We need context, let's get it +            context = await self.bot.get_context(msg) -            # For multiple messages or those with excessive newlines, use the logs API -            if len(messages) > 1 or rule_name == 'newlines': -                url = await self.mod_log.upload_log(messages, msg.guild.me.id) -                mod_alert_message += f"A complete log of the offending messages can be found [here]({url})" -            else: -                mod_alert_message += "Message:\n" -                content = messages[0].clean_content -                remaining_chars = 2040 - len(mod_alert_message) - -                if len(content) > remaining_chars: -                    content = content[:remaining_chars] + "..." - -                mod_alert_message += f"{content}" - -            # Return the mod log message Context that we can use to post the infraction -            mod_log_ctx = await self.mod_log.send_log_message( -                icon_url=Icons.filtering, -                colour=Colour(Colours.soft_red), -                title=f"Spam detected!", -                text=mod_alert_message, -                thumbnail=msg.author.avatar_url_as(static_format="png"), -                channel_id=Channels.mod_alerts, -                ping_everyone=AntiSpamConfig.ping_everyone +            # Since we're going to invoke the tempmute command directly, we need to manually call the converter. +            dt_remove_role_after = await self.expiration_date_converter.convert(context, f"{remove_role_after}S") +            await context.invoke( +                self.bot.get_command('tempmute'), +                member, +                dt_remove_role_after, +                reason=reason              ) -            # Run a tempmute -            await mod_log_ctx.invoke(Moderation.tempmute, member, f"{remove_role_after}S", reason=reason) -      async def maybe_delete_messages(self, channel: TextChannel, messages: List[Message]) -> None: -        """Determine whether flagged messages should be disabled & delete them if so.""" -        # Is deletion of offending messages actually enabled? +        """Cleans the messages if cleaning is configured."""          if AntiSpamConfig.clean_offending: -              # If we have more than one message, we can use bulk delete.              if len(messages) > 1:                  message_ids = [message.id for message in messages] @@ -157,28 +232,47 @@ class AntiSpam:              # Delete the message directly instead.              else:                  self.mod_log.ignore(Event.message_delete, messages[0].id) -                await messages[0].delete() +                try: +                    await messages[0].delete() +                except NotFound: +                    log.info(f"Tried to delete message `{messages[0].id}`, but message could not be found.") + +    async def _process_deletion_context(self, context_id: int) -> None: +        """Processes the Deletion Context queue.""" +        log.trace("Sleeping before processing message deletion queue.") +        await asyncio.sleep(10) + +        if context_id not in self.message_deletion_queue: +            log.error(f"Started processing deletion queue for context `{context_id}`, but it was not found!") +            return + +        deletion_context = self.message_deletion_queue.pop(context_id) +        await deletion_context.upload_messages(self.bot.user.id, self.mod_log) -def validate_config() -> None: -    """Validate loaded antispam filter configuration(s).""" -    for name, config in AntiSpamConfig.rules.items(): +def validate_config(rules: Mapping = AntiSpamConfig.rules) -> Dict[str, str]: +    """Validates the antispam configs.""" +    validation_errors = {} +    for name, config in rules.items():          if name not in RULE_FUNCTION_MAPPING: -            raise ValueError( +            log.error(                  f"Unrecognized antispam rule `{name}`. "                  f"Valid rules are: {', '.join(RULE_FUNCTION_MAPPING)}"              ) - +            validation_errors[name] = f"`{name}` is not recognized as an antispam rule." +            continue          for required_key in ('interval', 'max'):              if required_key not in config: -                raise ValueError( +                log.error(                      f"`{required_key}` is required but was not "                      f"set in rule `{name}`'s configuration."                  ) +                validation_errors[name] = f"Key `{required_key}` is required but not set for rule `{name}`" +    return validation_errors  def setup(bot: Bot) -> None:      """Antispam cog load.""" -    validate_config() -    bot.add_cog(AntiSpam(bot)) +    validation_errors = validate_config() +    bot.add_cog(AntiSpam(bot, validation_errors))      log.info("Cog loaded: AntiSpam") diff --git a/bot/cogs/bot.py b/bot/cogs/bot.py index be7922ef9..585be5a42 100644 --- a/bot/cogs/bot.py +++ b/bot/cogs/bot.py @@ -5,19 +5,18 @@ import time  from typing import Optional, Tuple, Union  from discord import Embed, Message, RawMessageUpdateEvent -from discord.ext.commands import Bot, Context, command, group +from discord.ext.commands import Bot, Cog, Context, command, group -from bot.constants import ( -    Channels, Guild, MODERATION_ROLES, -    Roles, URLs, -) +from bot.constants import Channels, DEBUG_MODE, Guild, MODERATION_ROLES, Roles, URLs  from bot.decorators import with_role  from bot.utils.messages import wait_for_deletion  log = logging.getLogger(__name__) +RE_MARKDOWN = re.compile(r'([*_~`|>])') -class Bot: + +class Bot(Cog):      """Bot information commands."""      def __init__(self, bot: Bot): @@ -31,6 +30,8 @@ class Bot:              Channels.help_3: 0,              Channels.help_4: 0,              Channels.help_5: 0, +            Channels.help_6: 0, +            Channels.help_7: 0,              Channels.python: 0,          } @@ -45,23 +46,23 @@ class Bot:      @group(invoke_without_command=True, name="bot", hidden=True)      @with_role(Roles.verified) -    async def bot_group(self, ctx: Context) -> None: +    async def botinfo_group(self, ctx: Context) -> None:          """Bot informational commands."""          await ctx.invoke(self.bot.get_command("help"), "bot") -    @bot_group.command(name='about', aliases=('info',), hidden=True) +    @botinfo_group.command(name='about', aliases=('info',), hidden=True)      @with_role(Roles.verified)      async def about_command(self, ctx: Context) -> None:          """Get information about the bot."""          embed = Embed(              description="A utility bot designed just for the Python server! Try `!help` for more info.", -            url="https://gitlab.com/discord-python/projects/bot" +            url="https://github.com/python-discord/bot"          )          embed.add_field(name="Total Users", value=str(len(self.bot.get_guild(Guild.id).members)))          embed.set_author(              name="Python Bot", -            url="https://gitlab.com/discord-python/projects/bot", +            url="https://github.com/python-discord/bot",              icon_url=URLs.bot_avatar          ) @@ -219,6 +220,7 @@ class Bot:          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. @@ -237,7 +239,7 @@ class Bot:          if parse_codeblock:              on_cooldown = (time.time() - self.channel_cooldowns.get(msg.channel.id, 0)) < 300 -            if not on_cooldown: +            if not on_cooldown or DEBUG_MODE:                  try:                      if self.has_bad_ticks(msg):                          ticks = msg.content[:3] @@ -262,13 +264,14 @@ class Bot:                                  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}\n\\`\\`\\`\n\n**This will result in the following:**\n" +                            f"\\`\\`\\`python\n{content_escaped_markdown}\n\\`\\`\\`\n\n" +                            "**This will result in the following:**\n"                              f"```python\n{content}\n```"                          ) @@ -304,13 +307,15 @@ class Bot:                                      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}\n\\`\\`\\`\n\n**This will result in the following:**\n" +                                f"\\`\\`\\`python\n{content_escaped_markdown}\n\\`\\`\\`\n\n" +                                "**This will result in the following:**\n"                                  f"```python\n{content}\n```"                              ) @@ -340,6 +345,7 @@ class Bot:                          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 ( @@ -354,14 +360,14 @@ class Bot:          # Retrieve channel and message objects for use later          channel = self.bot.get_channel(int(payload.data.get("channel_id"))) -        user_message = await channel.get_message(payload.message_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.get_message(self.codeblock_message_ids[payload.message_id]) +            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.") diff --git a/bot/cogs/clean.py b/bot/cogs/clean.py index 2c889c9f2..da1ae8b9b 100644 --- a/bot/cogs/clean.py +++ b/bot/cogs/clean.py @@ -4,7 +4,7 @@ import re  from typing import Optional  from discord import Colour, Embed, Message, User -from discord.ext.commands import Bot, Context, group +from discord.ext.commands import Bot, Cog, Context, group  from bot.cogs.modlog import ModLog  from bot.constants import ( @@ -16,7 +16,7 @@ from bot.decorators import with_role  log = logging.getLogger(__name__) -class Clean: +class Clean(Cog):      """      A cog that allows messages to be deleted in bulk, while applying various filters. @@ -110,7 +110,8 @@ class Clean:          self.cleaning = True          invocation_deleted = False -        async for message in ctx.channel.history(limit=amount): +        # To account for the invocation message, we index `amount + 1` messages. +        async for message in ctx.channel.history(limit=amount + 1):              # If at any point the cancel command is invoked, we should stop.              if not self.cleaning: diff --git a/bot/cogs/cogs.py b/bot/cogs/cogs.py index e6fa92927..fcb987a07 100644 --- a/bot/cogs/cogs.py +++ b/bot/cogs/cogs.py @@ -2,7 +2,7 @@ import logging  import os  from discord import Colour, Embed -from discord.ext.commands import Bot, Context, group +from discord.ext.commands import Bot, Cog, Context, group  from bot.constants import (      Emojis, MODERATION_ROLES, Roles, URLs @@ -15,7 +15,7 @@ log = logging.getLogger(__name__)  KEEP_LOADED = ["bot.cogs.cogs", "bot.cogs.modlog"] -class Cogs: +class Cogs(Cog):      """Cog management commands."""      def __init__(self, bot: Bot): @@ -35,13 +35,13 @@ class Cogs:          self.cogs.update({v: k for k, v in self.cogs.items()})      @group(name='cogs', aliases=('c',), invoke_without_command=True) -    @with_role(*MODERATION_ROLES, Roles.devops) +    @with_role(*MODERATION_ROLES, Roles.core_developer)      async def cogs_group(self, ctx: Context) -> None:          """Load, unload, reload, and list active cogs."""          await ctx.invoke(self.bot.get_command("help"), "cogs")      @cogs_group.command(name='load', aliases=('l',)) -    @with_role(*MODERATION_ROLES, Roles.devops) +    @with_role(*MODERATION_ROLES, Roles.core_developer)      async def load_command(self, ctx: Context, cog: str) -> None:          """          Load up an unloaded cog, given the module containing it. @@ -56,7 +56,7 @@ class Cogs:          embed.set_author(              name="Python Bot (Cogs)", -            url=URLs.gitlab_bot_repo, +            url=URLs.github_bot_repo,              icon_url=URLs.bot_avatar          ) @@ -80,8 +80,8 @@ class Cogs:                  except Exception as e:                      log.error(f"{ctx.author} requested we load the '{cog}' cog, "                                "but the loading failed with the following error: \n" -                              f"{e}") -                    embed.description = f"Failed to load cog: {cog}\n\n```{e}```" +                              f"**{e.__class__.__name__}: {e}**") +                    embed.description = f"Failed to load cog: {cog}\n\n{e.__class__.__name__}: {e}"                  else:                      log.debug(f"{ctx.author} requested we load the '{cog}' cog. Cog loaded!")                      embed.description = f"Cog loaded: {cog}" @@ -93,7 +93,7 @@ class Cogs:          await ctx.send(embed=embed)      @cogs_group.command(name='unload', aliases=('ul',)) -    @with_role(*MODERATION_ROLES, Roles.devops) +    @with_role(*MODERATION_ROLES, Roles.core_developer)      async def unload_command(self, ctx: Context, cog: str) -> None:          """          Unload an already-loaded cog, given the module containing it. @@ -108,7 +108,7 @@ class Cogs:          embed.set_author(              name="Python Bot (Cogs)", -            url=URLs.gitlab_bot_repo, +            url=URLs.github_bot_repo,              icon_url=URLs.bot_avatar          ) @@ -144,7 +144,7 @@ class Cogs:          await ctx.send(embed=embed)      @cogs_group.command(name='reload', aliases=('r',)) -    @with_role(*MODERATION_ROLES, Roles.devops) +    @with_role(*MODERATION_ROLES, Roles.core_developer)      async def reload_command(self, ctx: Context, cog: str) -> None:          """          Reload an unloaded cog, given the module containing it. @@ -162,7 +162,7 @@ class Cogs:          embed.set_author(              name="Python Bot (Cogs)", -            url=URLs.gitlab_bot_repo, +            url=URLs.github_bot_repo,              icon_url=URLs.bot_avatar          ) @@ -194,7 +194,7 @@ class Cogs:                      try:                          self.bot.unload_extension(loaded_cog)                      except Exception as e: -                        failed_unloads[loaded_cog] = str(e) +                        failed_unloads[loaded_cog] = f"{e.__class__.__name__}: {e}"                      else:                          unloaded += 1 @@ -202,7 +202,7 @@ class Cogs:                      try:                          self.bot.load_extension(unloaded_cog)                      except Exception as e: -                        failed_loads[unloaded_cog] = str(e) +                        failed_loads[unloaded_cog] = f"{e.__class__.__name__}: {e}"                      else:                          loaded += 1 @@ -215,13 +215,13 @@ class Cogs:                      lines.append("\n**Unload failures**")                      for cog, error in failed_unloads: -                        lines.append(f"`{cog}` {Emojis.status_dnd} `{error}`") +                        lines.append(f"{Emojis.status_dnd} **{cog}:** `{error}`")                  if failed_loads:                      lines.append("\n**Load failures**") -                    for cog, error in failed_loads: -                        lines.append(f"`{cog}` {Emojis.status_dnd} `{error}`") +                    for cog, error in failed_loads.items(): +                        lines.append(f"{Emojis.status_dnd} **{cog}:** `{error}`")                  log.debug(f"{ctx.author} requested we reload all cogs. Here are the results: \n"                            f"{lines}") @@ -248,7 +248,7 @@ class Cogs:          await ctx.send(embed=embed)      @cogs_group.command(name='list', aliases=('all',)) -    @with_role(*MODERATION_ROLES, Roles.devops) +    @with_role(*MODERATION_ROLES, Roles.core_developer)      async def list_command(self, ctx: Context) -> None:          """          Get a list of all cogs, including their loaded status. @@ -262,7 +262,7 @@ class Cogs:          embed.colour = Colour.blurple()          embed.set_author(              name="Python Bot (Cogs)", -            url=URLs.gitlab_bot_repo, +            url=URLs.github_bot_repo,              icon_url=URLs.bot_avatar          ) diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py index fc4dca0ee..9d530af64 100644 --- a/bot/cogs/defcon.py +++ b/bot/cogs/defcon.py @@ -2,10 +2,10 @@ import logging  from datetime import datetime, timedelta  from discord import Colour, Embed, Member -from discord.ext.commands import Bot, Context, group +from discord.ext.commands import Bot, Cog, Context, group  from bot.cogs.modlog import ModLog -from bot.constants import Channels, Colours, Emojis, Event, Icons, Keys, Roles +from bot.constants import Channels, Colours, Emojis, Event, Icons, Roles  from bot.decorators import with_role  log = logging.getLogger(__name__) @@ -24,7 +24,7 @@ will be resolved soon. In the meantime, please feel free to peruse the resources  BASE_CHANNEL_TOPIC = "Python Discord Defense Mechanism" -class Defcon: +class Defcon(Cog):      """Time-sensitive server defense mechanisms."""      days = None  # type: timedelta @@ -32,16 +32,18 @@ class Defcon:      def __init__(self, bot: Bot):          self.bot = bot +        self.channel = None          self.days = timedelta(days=0) -        self.headers = {"X-API-KEY": Keys.site_api}      @property      def mod_log(self) -> ModLog:          """Get currently loaded ModLog cog instance."""          return self.bot.get_cog("ModLog") +    @Cog.listener()      async def on_ready(self) -> None:          """On cog load, try to synchronize DEFCON settings to the API.""" +        self.channel = await self.bot.fetch_channel(Channels.defcon)          try:              response = await self.bot.api_client.get('bot/bot-settings/defcon')              data = response['data'] @@ -65,6 +67,7 @@ class Defcon:              await self.update_channel_topic() +    @Cog.listener()      async def on_member_join(self, member: Member) -> None:          """If DEFON is enabled, check newly joining users to see if they meet the account age threshold."""          if self.enabled and self.days.days > 0: @@ -268,8 +271,7 @@ class Defcon:              new_topic = f"{BASE_CHANNEL_TOPIC}\n(Status: Disabled)"          self.mod_log.ignore(Event.guild_channel_update, Channels.defcon) -        defcon_channel = self.bot.guilds[0].get_channel(Channels.defcon) -        await defcon_channel.edit(topic=new_topic) +        await self.channel.edit(topic=new_topic)  def setup(bot: Bot) -> None: diff --git a/bot/cogs/deployment.py b/bot/cogs/deployment.py deleted file mode 100644 index e8e8ba677..000000000 --- a/bot/cogs/deployment.py +++ /dev/null @@ -1,79 +0,0 @@ -import logging - -from discord import Colour, Embed -from discord.ext.commands import Bot, Context, command, group - -from bot.constants import Keys, MODERATION_ROLES, Roles, URLs -from bot.decorators import with_role - -log = logging.getLogger(__name__) - - -class Deployment: -    """Bot information commands.""" - -    def __init__(self, bot: Bot): -        self.bot = bot - -    @group(name='redeploy', invoke_without_command=True) -    @with_role(*MODERATION_ROLES) -    async def redeploy_group(self, ctx: Context) -> None: -        """Redeploy the bot or the site.""" -        await ctx.invoke(self.bot.get_command("help"), "redeploy") - -    @redeploy_group.command(name='bot') -    @with_role(Roles.admin, Roles.owner, Roles.devops) -    async def bot_command(self, ctx: Context) -> None: -        """Trigger bot deployment on the server - will only redeploy if there were changes to deploy.""" -        response = await self.bot.http_session.get(URLs.deploy, headers={"token": Keys.deploy_bot}) -        result = await response.text() - -        if result == "True": -            log.debug(f"{ctx.author} triggered deployment for bot. Deployment was started.") -            await ctx.send(f"{ctx.author.mention} Bot deployment started.") -        else: -            log.error(f"{ctx.author} triggered deployment for bot. Deployment failed to start.") -            await ctx.send(f"{ctx.author.mention} Bot deployment failed - check the logs!") - -    @redeploy_group.command(name='site') -    @with_role(Roles.admin, Roles.owner, Roles.devops) -    async def site_command(self, ctx: Context) -> None: -        """Trigger website deployment on the server - will only redeploy if there were changes to deploy.""" -        response = await self.bot.http_session.get(URLs.deploy, headers={"token": Keys.deploy_bot}) -        result = await response.text() - -        if result == "True": -            log.debug(f"{ctx.author} triggered deployment for site. Deployment was started.") -            await ctx.send(f"{ctx.author.mention} Site deployment started.") -        else: -            log.error(f"{ctx.author} triggered deployment for site. Deployment failed to start.") -            await ctx.send(f"{ctx.author.mention} Site deployment failed - check the logs!") - -    @command(name='uptimes') -    @with_role(Roles.admin, Roles.owner, Roles.devops) -    async def uptimes_command(self, ctx: Context) -> None: -        """Check the various deployment uptimes for each service.""" -        log.debug(f"{ctx.author} requested service uptimes.") -        response = await self.bot.http_session.get(URLs.status) -        data = await response.json() - -        embed = Embed( -            title="Service status", -            color=Colour.blurple() -        ) - -        for obj in data: -            key, value = list(obj.items())[0] - -            embed.add_field( -                name=key, value=value, inline=True -            ) - -        log.debug("Uptimes retrieved and parsed, returning data.") -        await ctx.send(embed=embed) - - -def setup(bot: Bot) -> None: -    """Deployment cog load.""" -    bot.add_cog(Deployment(bot)) -    log.info("Cog loaded: Deployment") diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index ef14d1797..54a3172b8 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -118,7 +118,7 @@ class InventoryURL(commands.Converter):          return url -class Doc: +class Doc(commands.Cog):      """A set of commands for querying & displaying documentation."""      def __init__(self, bot: commands.Bot): @@ -126,6 +126,7 @@ class Doc:          self.bot = bot          self.inventories = {} +    @commands.Cog.listener()      async def on_ready(self) -> None:          """Refresh documentation inventory."""          await self.refresh_inventory() diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index f8a27fda8..e74030c16 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -4,26 +4,32 @@ import logging  from discord.ext.commands import (      BadArgument,      BotMissingPermissions, +    CheckFailure,      CommandError,      CommandInvokeError,      CommandNotFound, +    CommandOnCooldown, +    DisabledCommand, +    MissingPermissions,      NoPrivateMessage,      UserInputError,  ) -from discord.ext.commands import Bot, Context +from discord.ext.commands import Bot, Cog, Context  from bot.api import ResponseCodeError - +from bot.constants import Channels +from bot.decorators import InChannelCheckFailure  log = logging.getLogger(__name__) -class ErrorHandler: +class ErrorHandler(Cog):      """Handles errors emitted from commands."""      def __init__(self, bot: Bot):          self.bot = bot +    @Cog.listener()      async def on_command_error(self, ctx: Context, e: CommandError) -> None:          """Provide command error handling."""          command = ctx.command @@ -32,6 +38,7 @@ class ErrorHandler:          if command is not None:              parent = command.parent +        # Retrieve the help command for the invoked command.          if parent and command:              help_command = (self.bot.get_command("help"), parent.name, command.name)          elif command: @@ -39,53 +46,80 @@ class ErrorHandler:          else:              help_command = (self.bot.get_command("help"),) -        if hasattr(command, "on_error"): -            log.debug(f"Command {command} has a local error handler, ignoring.") +        if hasattr(e, "handled"): +            log.trace(f"Command {command} had its error already handled locally; ignoring.")              return +        # Try to look for a tag with the command's name if the command isn't found.          if isinstance(e, CommandNotFound) and not hasattr(ctx, "invoked_from_error_handler"): -            tags_get_command = self.bot.get_command("tags get") -            ctx.invoked_from_error_handler = True +            if not ctx.channel.id == Channels.verification: +                tags_get_command = self.bot.get_command("tags get") +                ctx.invoked_from_error_handler = True -            # Return to not raise the exception -            with contextlib.suppress(ResponseCodeError): -                return await ctx.invoke(tags_get_command, tag_name=ctx.invoked_with) +                # Return to not raise the exception +                with contextlib.suppress(ResponseCodeError): +                    return await ctx.invoke(tags_get_command, tag_name=ctx.invoked_with)          elif isinstance(e, BadArgument):              await ctx.send(f"Bad argument: {e}\n")              await ctx.invoke(*help_command)          elif isinstance(e, UserInputError):              await ctx.send("Something about your input seems off. Check the arguments:")              await ctx.invoke(*help_command) +            log.debug( +                f"Command {command} invoked by {ctx.message.author} with error " +                f"{e.__class__.__name__}: {e}" +            )          elif isinstance(e, NoPrivateMessage):              await ctx.send("Sorry, this command can't be used in a private message!")          elif isinstance(e, BotMissingPermissions): -            await ctx.send( -                f"Sorry, it looks like I don't have the permissions I need to do that.\n\n" -                f"Here's what I'm missing: **{e.missing_perms}**" +            await ctx.send(f"Sorry, it looks like I don't have the permissions I need to do that.") +            log.warning( +                f"The bot is missing permissions to execute command {command}: {e.missing_perms}" +            ) +        elif isinstance(e, MissingPermissions): +            log.debug( +                f"{ctx.message.author} is missing permissions to invoke command {command}: " +                f"{e.missing_perms}" +            ) +        elif isinstance(e, InChannelCheckFailure): +            await ctx.send(e) +        elif isinstance(e, (CheckFailure, CommandOnCooldown, DisabledCommand)): +            log.debug( +                f"Command {command} invoked by {ctx.message.author} with error " +                f"{e.__class__.__name__}: {e}"              )          elif isinstance(e, CommandInvokeError):              if isinstance(e.original, ResponseCodeError): -                if e.original.response.status == 404: +                status = e.original.response.status + +                if status == 404:                      await ctx.send("There does not seem to be anything matching your query.") -                elif e.original.response.status == 400: +                elif status == 400:                      content = await e.original.response.json() -                    log.debug("API gave bad request on command. Response: %r.", content) +                    log.debug(f"API responded with 400 for command {command}: %r.", content)                      await ctx.send("According to the API, your request is malformed.") -                elif 500 <= e.original.response.status < 600: +                elif 500 <= status < 600:                      await ctx.send("Sorry, there seems to be an internal issue with the API.") +                    log.warning(f"API responded with {status} for command {command}")                  else: -                    await ctx.send( -                        "Got an unexpected status code from the " -                        f"API (`{e.original.response.code}`)." -                    ) - +                    await ctx.send(f"Got an unexpected status code from the API (`{status}`).") +                    log.warning(f"Unexpected API response for command {command}: {status}")              else: -                await ctx.send( -                    f"Sorry, an unexpected error occurred. Please let us know!\n\n```{e}```" -                ) -                raise e.original +                await self.handle_unexpected_error(ctx, e.original)          else: -            raise e +            await self.handle_unexpected_error(ctx, e) + +    @staticmethod +    async def handle_unexpected_error(ctx: Context, e: CommandError) -> None: +        """Generic handler for errors without an explicit handler.""" +        await ctx.send( +            f"Sorry, an unexpected error occurred. Please let us know!\n\n" +            f"```{e.__class__.__name__}: {e}```" +        ) +        log.error( +            f"Error executing command invoked by {ctx.message.author}: {ctx.message.content}" +        ) +        raise e  def setup(bot: Bot) -> None: diff --git a/bot/cogs/eval.py b/bot/cogs/eval.py index 5fbd9dca5..578a494e7 100644 --- a/bot/cogs/eval.py +++ b/bot/cogs/eval.py @@ -9,7 +9,7 @@ from io import StringIO  from typing import Any, Tuple, Union  import discord -from discord.ext.commands import Bot, group +from discord.ext.commands import Bot, Cog, group  from bot.constants import Roles  from bot.decorators import with_role @@ -18,7 +18,7 @@ from bot.interpreter import Interpreter  log = logging.getLogger(__name__) -class CodeEval: +class CodeEval(Cog):      """Owner and admin feature that evaluates code and returns the result to the channel."""      def __init__(self, bot: Bot): diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index b924ac265..2eb53e61b 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -5,7 +5,7 @@ from typing import Optional, Union  import discord.errors  from dateutil.relativedelta import relativedelta  from discord import Colour, DMChannel, Member, Message, TextChannel -from discord.ext.commands import Bot +from discord.ext.commands import Bot, Cog  from bot.cogs.modlog import ModLog  from bot.constants import ( @@ -29,7 +29,7 @@ URL_RE = r"(https?://[^\s]+)"  ZALGO_RE = r"[\u0300-\u036F\u0489]" -class Filtering: +class Filtering(Cog):      """Filtering out invites, blacklisting domains, and warning us of certain regular expressions."""      def __init__(self, bot: Bot): @@ -56,7 +56,7 @@ class Filtering:                  "user_notification": Filter.notify_user_invites,                  "notification_msg": (                      f"Per Rule 10, your invite link has been removed. {_staff_mistake_str}\n\n" -                    r"Our server rules can be found here: <https://pythondiscord.com/about/rules>" +                    r"Our server rules can be found here: <https://pythondiscord.com/pages/rules>"                  )              },              "filter_domains": { @@ -94,10 +94,12 @@ class Filtering:          """Get currently loaded ModLog cog instance."""          return self.bot.get_cog("ModLog") +    @Cog.listener()      async def on_message(self, msg: Message) -> None:          """Invoke message filter for new messages."""          await self._filter_message(msg) +    @Cog.listener()      async def on_message_edit(self, before: Message, after: Message) -> None:          """          Invoke message filter for message edits. @@ -107,7 +109,7 @@ class Filtering:          if not before.edited_at:              delta = relativedelta(after.edited_at, before.created_at).microseconds          else: -            delta = None +            delta = relativedelta(after.edited_at, before.edited_at).microseconds          await self._filter_message(after, delta)      async def _filter_message(self, msg: Message, delta: Optional[int] = None) -> None: @@ -140,7 +142,7 @@ class Filtering:                          # If the edit delta is less than 0.001 seconds, then we're probably dealing                          # with a double filter trigger.                          if delta is not None and delta < 100: -                            return +                            continue                      # Does the filter only need the message content or the full message?                      if _filter["content_only"]: diff --git a/bot/cogs/free.py b/bot/cogs/free.py index ccc722e66..167fab319 100644 --- a/bot/cogs/free.py +++ b/bot/cogs/free.py @@ -2,7 +2,7 @@ import logging  from datetime import datetime  from discord import Colour, Embed, Member, utils -from discord.ext.commands import Bot, Context, command +from discord.ext.commands import Bot, Cog, Context, command  from bot.constants import Categories, Channels, Free, STAFF_ROLES  from bot.decorators import redirect_output @@ -15,7 +15,7 @@ RATE = Free.cooldown_rate  PER = Free.cooldown_per -class Free: +class Free(Cog):      """Tries to figure out which help channels are free."""      PYTHON_HELP_ID = Categories.python_help diff --git a/bot/cogs/help.py b/bot/cogs/help.py index 68c59d326..63421c8a7 100644 --- a/bot/cogs/help.py +++ b/bot/cogs/help.py @@ -7,7 +7,7 @@ from typing import Union  from discord import Colour, Embed, HTTPException, Message, Reaction, User  from discord.ext import commands -from discord.ext.commands import Bot, CheckFailure, Command, Context +from discord.ext.commands import Bot, CheckFailure, Cog as DiscordCog, Command, Context  from fuzzywuzzy import fuzz, process  from bot import constants @@ -110,7 +110,7 @@ class HelpSession:              self.query = ctx.bot              self.description = self.query.description          self.author = ctx.author -        self.destination = ctx.author if ctx.bot.pm_help else ctx.channel +        self.destination = ctx.channel          # set the config for the session          self._cleanup = cleanup @@ -528,7 +528,7 @@ class HelpSession:          await self.message.delete() -class Help: +class Help(DiscordCog):      """Custom Embed Pagination Help feature."""      @commands.command('help') diff --git a/bot/cogs/information.py b/bot/cogs/information.py index 3495f6181..60aec6219 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -1,28 +1,22 @@  import logging -import random  import textwrap  from discord import CategoryChannel, Colour, Embed, Member, TextChannel, VoiceChannel -from discord.ext.commands import ( -    BadArgument, Bot, CommandError, Context, MissingPermissions, command -) - -from bot.constants import ( -    Channels, Emojis, Keys, MODERATION_ROLES, NEGATIVE_REPLIES, STAFF_ROLES -) -from bot.decorators import with_role +from discord.ext.commands import Bot, Cog, Context, command + +from bot.constants import Channels, Emojis, MODERATION_ROLES, STAFF_ROLES +from bot.decorators import InChannelCheckFailure, with_role  from bot.utils.checks import with_role_check  from bot.utils.time import time_since  log = logging.getLogger(__name__) -class Information: +class Information(Cog):      """A cog with commands for generating embeds with server info, such as server stats and user info."""      def __init__(self, bot: Bot):          self.bot = bot -        self.headers = {"X-API-Key": Keys.site_api}      @with_role(*MODERATION_ROLES)      @command(name="roles") @@ -116,30 +110,25 @@ class Information:      @command(name="user", aliases=["user_info", "member", "member_info"])      async def user_info(self, ctx: Context, user: Member = None, hidden: bool = False) -> None:          """Returns info about a user.""" -        # Do a role check if this is being executed on -        # someone other than the caller -        if user and user != ctx.author: -            if not with_role_check(ctx, *MODERATION_ROLES): -                raise BadArgument("You do not have permission to use this command on users other than yourself.") - -        # Non-moderators may only do this in #bot-commands and can't see -        # hidden infractions. +        if user is None: +            user = ctx.author + +        # Do a role check if this is being executed on someone other than the caller +        if user != ctx.author and not with_role_check(ctx, *MODERATION_ROLES): +            await ctx.send("You may not use this command on users other than yourself.") +            return + +        # Non-moderators may only do this in #bot-commands and can't see hidden infractions.          if not with_role_check(ctx, *STAFF_ROLES):              if not ctx.channel.id == Channels.bot: -                raise MissingPermissions("You can't do that here!") +                raise InChannelCheckFailure(Channels.bot)              # Hide hidden infractions for users without a moderation role              hidden = False -        # Validates hidden input -        hidden = str(hidden) - -        if user is None: -            user = ctx.author -          # User information          created = time_since(user.created_at, max_units=3) -        name = f"{user.name}#{user.discriminator}" +        name = str(user)          if user.nick:              name = f"{user.nick} ({name})" @@ -147,15 +136,13 @@ class Information:          joined = time_since(user.joined_at, precision="days")          # You're welcome, Volcyyyyyyyyyyyyyyyy -        roles = ", ".join( -            role.mention for role in user.roles if role.name != "@everyone" -        ) +        roles = ", ".join(role.mention for role in user.roles if role.name != "@everyone")          # Infractions          infractions = await self.bot.api_client.get(              'bot/infractions',              params={ -                'hidden': hidden, +                'hidden': str(hidden),                  'user__id': str(user.id)              }          ) @@ -194,24 +181,6 @@ class Information:          await ctx.send(embed=embed) -    @user_info.error -    async def user_info_command_error(self, ctx: Context, error: CommandError) -> None: -        """Info commands error handler.""" -        embed = Embed(colour=Colour.red()) - -        if isinstance(error, BadArgument): -            embed.title = random.choice(NEGATIVE_REPLIES) -            embed.description = str(error) -            await ctx.send(embed=embed) - -        elif isinstance(error, MissingPermissions): -            embed.title = random.choice(NEGATIVE_REPLIES) -            embed.description = f"Sorry, but you may only use this command within <#{Channels.bot}>." -            await ctx.send(embed=embed) - -        else: -            log.exception(f"Unhandled error: {error}") -  def setup(bot: Bot) -> None:      """Information cog load.""" diff --git a/bot/cogs/jams.py b/bot/cogs/jams.py index f7a5896c0..be9d33e3e 100644 --- a/bot/cogs/jams.py +++ b/bot/cogs/jams.py @@ -2,6 +2,7 @@ import logging  from discord import Member, PermissionOverwrite, utils  from discord.ext import commands +from more_itertools import unique_everseen  from bot.constants import Roles  from bot.decorators import with_role @@ -9,7 +10,7 @@ from bot.decorators import with_role  log = logging.getLogger(__name__) -class CodeJams: +class CodeJams(commands.Cog):      """Manages the code-jam related parts of our server."""      def __init__(self, bot: commands.Bot): @@ -17,23 +18,25 @@ class CodeJams:      @commands.command()      @with_role(Roles.admin) -    async def createteam( -        self, ctx: commands.Context, -        team_name: str, members: commands.Greedy[Member] -    ) -> None: +    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.          The first user passed will always be the team leader.          """ +        # Ignore duplicate members +        members = list(unique_everseen(members)) +          # We had a little issue during Code Jam 4 here, the greedy converter did it's job          # and ignored anything which wasn't a valid argument which left us with teams of          # two members or at some times even 1 member. This fixes that by checking that there          # are always 3 members in the members list.          if len(members) < 3: -            await ctx.send(":no_entry_sign: One of your arguments was invalid - there must be a " -                           f"minimum of 3 valid members in your team. Found: {len(members)} " -                           "members") +            await ctx.send( +                ":no_entry_sign: One of your arguments was invalid\n" +                f"There must be a minimum of 3 valid members in your team. Found: {len(members)}" +                " members" +            )              return          code_jam_category = utils.get(ctx.guild.categories, name="Code Jam") @@ -61,7 +64,7 @@ class CodeJams:                  connect=True              ),              ctx.guild.default_role: PermissionOverwrite(read_messages=False, connect=False), -            ctx.guild.get_role(Roles.developer): PermissionOverwrite( +            ctx.guild.get_role(Roles.verified): PermissionOverwrite(                  read_messages=False,                  connect=False              ) @@ -98,7 +101,11 @@ class CodeJams:          for member in members:              await member.add_roles(jammer_role) -        await ctx.send(f":ok_hand: Team created: {team_channel.mention}") +        await ctx.send( +            f":ok_hand: Team created: {team_channel.mention}\n" +            f"**Team Leader:** {members[0].mention}\n" +            f"**Team Members:** {' '.join(member.mention for member in members[1:])}" +        )  def setup(bot: commands.Bot) -> None: diff --git a/bot/cogs/logging.py b/bot/cogs/logging.py index 22d770c04..8e47bcc36 100644 --- a/bot/cogs/logging.py +++ b/bot/cogs/logging.py @@ -1,7 +1,7 @@  import logging  from discord import Embed -from discord.ext.commands import Bot +from discord.ext.commands import Bot, Cog  from bot.constants import Channels, DEBUG_MODE @@ -9,12 +9,13 @@ from bot.constants import Channels, DEBUG_MODE  log = logging.getLogger(__name__) -class Logging: +class Logging(Cog):      """Debug logging module."""      def __init__(self, bot: Bot):          self.bot = bot +    @Cog.listener()      async def on_ready(self) -> None:          """Announce our presence to the configured devlog channel."""          log.info("Bot connected!") @@ -23,7 +24,10 @@ class Logging:          embed.set_author(              name="Python Bot",              url="https://github.com/python-discord/bot", -            icon_url="https://github.com/python-discord/branding/blob/master/logos/logo_circle/logo_circle_256.png" +            icon_url=( +                "https://raw.githubusercontent.com/" +                "python-discord/branding/master/logos/logo_circle/logo_circle_large.png" +            )          )          if not DEBUG_MODE: diff --git a/bot/cogs/moderation.py b/bot/cogs/moderation.py index 28956e636..d407a90fe 100644 --- a/bot/cogs/moderation.py +++ b/bot/cogs/moderation.py @@ -2,13 +2,13 @@ import asyncio  import logging  import textwrap  from datetime import datetime -from typing import Union +from typing import Dict, Union  from discord import ( -    Colour, Embed, Forbidden, Guild, HTTPException, Member, Object, User +    Colour, Embed, Forbidden, Guild, HTTPException, Member, NotFound, Object, User  )  from discord.ext.commands import ( -    BadArgument, BadUnionArgument, Bot, Context, command, group +    BadArgument, BadUnionArgument, Bot, Cog, Context, command, group  )  from bot import constants @@ -17,7 +17,7 @@ from bot.constants import Colours, Event, Icons, MODERATION_ROLES  from bot.converters import ExpirationDate, InfractionSearchQuery  from bot.decorators import with_role  from bot.pagination import LinePaginator -from bot.utils.moderation import post_infraction +from bot.utils.moderation import already_has_active_infraction, post_infraction  from bot.utils.scheduling import Scheduler, create_task  from bot.utils.time import wait_until @@ -28,7 +28,7 @@ INFRACTION_ICONS = {      "Kick": Icons.sign_out,      "Ban": Icons.user_ban  } -RULES_URL = "https://pythondiscord.com/about/rules" +RULES_URL = "https://pythondiscord.com/pages/rules"  APPEALABLE_INFRACTIONS = ("Ban", "Mute") @@ -47,7 +47,7 @@ def proxy_user(user_id: str) -> Object:  UserTypes = Union[Member, User, proxy_user] -class Moderation(Scheduler): +class Moderation(Scheduler, Cog):      """Server moderation tools."""      def __init__(self, bot: Bot): @@ -60,8 +60,10 @@ class Moderation(Scheduler):          """Get currently loaded ModLog cog instance."""          return self.bot.get_cog("ModLog") +    @Cog.listener()      async def on_ready(self) -> None:          """Schedule expiration for previous infractions.""" +        # Schedule expiration for previous infractions          infractions = await self.bot.api_client.get(              'bot/infractions', params={'active': 'true'}          ) @@ -74,21 +76,12 @@ class Moderation(Scheduler):      @with_role(*MODERATION_ROLES)      @command()      async def warn(self, ctx: Context, user: UserTypes, *, reason: str = None) -> None: -        """ -        Create a warning infraction in the database for a user. - -        **`user`:** Accepts user mention, ID, etc. -        **`reason`:** The reason for the warning. -        """ -        response_object = await post_infraction(ctx, user, type="warning", reason=reason) -        if response_object is None: +        """Create a warning infraction in the database for a user.""" +        infraction = await post_infraction(ctx, user, type="warning", reason=reason) +        if infraction is None:              return -        notified = await self.notify_infraction( -            user=user, -            infr_type="Warning", -            reason=reason -        ) +        notified = await self.notify_infraction(user=user, infr_type="Warning", reason=reason)          dm_result = ":incoming_envelope: " if notified else ""          action = f"{dm_result}:ok_hand: warned {user.mention}" @@ -117,7 +110,7 @@ class Moderation(Scheduler):                  Reason: {reason}              """),              content=log_content, -            footer=f"ID {response_object['infraction']['id']}" +            footer=f"ID {infraction['id']}"          )      @with_role(*MODERATION_ROLES) @@ -129,15 +122,11 @@ class Moderation(Scheduler):              # Warning is sent to ctx by the helper method              return -        response_object = await post_infraction(ctx, user, type="kick", reason=reason) -        if response_object is None: +        infraction = await post_infraction(ctx, user, type="kick", reason=reason) +        if infraction is None:              return -        notified = await self.notify_infraction( -            user=user, -            infr_type="Kick", -            reason=reason -        ) +        notified = await self.notify_infraction(user=user, infr_type="Kick", reason=reason)          self.mod_log.ignore(Event.member_remove, user.id) @@ -171,7 +160,7 @@ class Moderation(Scheduler):                  Reason: {reason}              """),              content=log_content, -            footer=f"ID {response_object['infraction']['id']}" +            footer=f"ID {infraction['id']}"          )      @with_role(*MODERATION_ROLES) @@ -183,22 +172,11 @@ class Moderation(Scheduler):              # Warning is sent to ctx by the helper method              return -        active_bans = await self.bot.api_client.get( -            'bot/infractions', -            params={ -                'active': 'true', -                'type': 'ban', -                'user__id': str(user.id) -            } -        ) -        if active_bans: -            return await ctx.send( -                ":x: According to my records, this user is already banned. " -                f"See infraction **#{active_bans[0]['id']}**." -            ) +        if await already_has_active_infraction(ctx=ctx, user=user, type="ban"): +            return -        response_object = await post_infraction(ctx, user, type="ban", reason=reason) -        if response_object is None: +        infraction = await post_infraction(ctx, user, type="ban", reason=reason) +        if infraction is None:              return          notified = await self.notify_infraction( @@ -242,29 +220,18 @@ class Moderation(Scheduler):                  Reason: {reason}              """),              content=log_content, -            footer=f"ID {response_object['infraction']['id']}" +            footer=f"ID {infraction['id']}"          )      @with_role(*MODERATION_ROLES)      @command()      async def mute(self, ctx: Context, user: Member, *, reason: str = None) -> None:          """Create a permanent mute infraction for a user with the provided reason.""" -        active_mutes = await self.bot.api_client.get( -            'bot/infractions', -            params={ -                'active': 'true', -                'type': 'mute', -                'user__id': str(user.id) -            } -        ) -        if active_mutes: -            return await ctx.send( -                ":x: According to my records, this user is already muted. " -                f"See infraction **#{active_mutes[0]['id']}**." -            ) +        if await already_has_active_infraction(ctx=ctx, user=user, type="mute"): +            return -        response_object = await post_infraction(ctx, user, type="mute", reason=reason) -        if response_object is None: +        infraction = await post_infraction(ctx, user, type="mute", reason=reason) +        if infraction is None:              return          self.mod_log.ignore(Event.member_update, user.id) @@ -304,7 +271,7 @@ class Moderation(Scheduler):                  Reason: {reason}              """),              content=log_content, -            footer=f"ID {response_object['infraction']['id']}" +            footer=f"ID {infraction['id']}"          )      # endregion @@ -312,34 +279,20 @@ class Moderation(Scheduler):      @with_role(*MODERATION_ROLES)      @command() -    async def tempmute( -            self, ctx: Context, user: Member, expiration: ExpirationDate, -            *, reason: str = None -    ) -> None: +    async def tempmute(self, ctx: Context, user: Member, duration: ExpirationDate, *, reason: str = None) -> None:          """          Create a temporary mute infraction for a user with the provided expiration and reason.          Duration strings are parsed per: http://strftime.org/          """ -        active_mutes = await self.bot.api_client.get( -            'bot/infractions', -            params={ -                'active': 'true', -                'type': 'mute', -                'user__id': str(user.id) -            } -        ) -        if active_mutes: -            return await ctx.send( -                ":x: According to my records, this user is already muted. " -                f"See infraction **#{active_mutes[0]['id']}**." -            ) +        expiration = duration -        infraction = await post_infraction( -            ctx, user, -            type="mute", reason=reason, -            expires_at=expiration -        ) +        if await already_has_active_infraction(ctx=ctx, user=user, type="mute"): +            return + +        infraction = await post_infraction(ctx, user, type="mute", reason=reason, expires_at=expiration) +        if infraction is None: +            return          self.mod_log.ignore(Event.member_update, user.id)          await user.add_roles(self._muted_role, reason=reason) @@ -392,44 +345,30 @@ class Moderation(Scheduler):      @with_role(*MODERATION_ROLES)      @command() -    async def tempban( -            self, ctx: Context, user: UserTypes, expiry: ExpirationDate, *, reason: str = None -    ) -> None: +    async def tempban(self, ctx: Context, user: UserTypes, duration: ExpirationDate, *, reason: str = None) -> None:          """          Create a temporary ban infraction for a user with the provided expiration and reason.          Duration strings are parsed per: http://strftime.org/          """ +        expiration = duration +          if not await self.respect_role_hierarchy(ctx, user, 'tempban'):              # Ensure ctx author has a higher top role than the target user              # Warning is sent to ctx by the helper method              return -        active_bans = await self.bot.api_client.get( -            'bot/infractions', -            params={ -                'active': 'true', -                'type': 'ban', -                'user__id': str(user.id) -            } -        ) -        if active_bans: -            return await ctx.send( -                ":x: According to my records, this user is already banned. " -                f"See infraction **#{active_bans[0]['id']}**." -            ) +        if await already_has_active_infraction(ctx=ctx, user=user, type="ban"): +            return -        infraction = await post_infraction( -            ctx, user, type="ban", -            reason=reason, expires_at=expiry -        ) +        infraction = await post_infraction(ctx, user, type="ban", reason=reason, expires_at=expiration)          if infraction is None:              return          notified = await self.notify_infraction(              user=user,              infr_type="Ban", -            expires_at=expiry, +            expires_at=expiration,              reason=reason          ) @@ -491,11 +430,8 @@ class Moderation(Scheduler):          This does not send the user a notification          """ -        response_object = await post_infraction( -            ctx, user, type="warning", reason=reason, hidden=True -        ) - -        if response_object is None: +        infraction = await post_infraction(ctx, user, type="warning", reason=reason, hidden=True) +        if infraction is None:              return          if reason is None: @@ -513,7 +449,7 @@ class Moderation(Scheduler):                  Actor: {ctx.message.author}                  Reason: {reason}              """), -            footer=f"ID {response_object['infraction']['id']}" +            footer=f"ID {infraction['id']}"          )      @with_role(*MODERATION_ROLES) @@ -529,8 +465,8 @@ class Moderation(Scheduler):              # Warning is sent to ctx by the helper method              return -        response_object = await post_infraction(ctx, user, type="kick", reason=reason, hidden=True) -        if response_object is None: +        infraction = await post_infraction(ctx, user, type="kick", reason=reason, hidden=True) +        if infraction is None:              return          self.mod_log.ignore(Event.member_remove, user.id) @@ -564,7 +500,7 @@ class Moderation(Scheduler):                  Reason: {reason}              """),              content=log_content, -            footer=f"ID {response_object['infraction']['id']}" +            footer=f"ID {infraction['id']}"          )      @with_role(*MODERATION_ROLES) @@ -580,8 +516,11 @@ class Moderation(Scheduler):              # Warning is sent to ctx by the helper method              return -        response_object = await post_infraction(ctx, user, type="ban", reason=reason, hidden=True) -        if response_object is None: +        if await already_has_active_infraction(ctx=ctx, user=user, type="ban"): +            return + +        infraction = await post_infraction(ctx, user, type="ban", reason=reason, hidden=True) +        if infraction is None:              return          self.mod_log.ignore(Event.member_ban, user.id) @@ -616,7 +555,7 @@ class Moderation(Scheduler):                  Reason: {reason}              """),              content=log_content, -            footer=f"ID {response_object['infraction']['id']}" +            footer=f"ID {infraction['id']}"          )      @with_role(*MODERATION_ROLES) @@ -627,8 +566,11 @@ class Moderation(Scheduler):          This does not send the user a notification.          """ -        response_object = await post_infraction(ctx, user, type="mute", reason=reason, hidden=True) -        if response_object is None: +        if await already_has_active_infraction(ctx=ctx, user=user, type="mute"): +            return + +        infraction = await post_infraction(ctx, user, type="mute", reason=reason, hidden=True) +        if infraction is None:              return          self.mod_log.ignore(Event.member_update, user.id) @@ -649,7 +591,7 @@ class Moderation(Scheduler):                  Actor: {ctx.message.author}                  Reason: {reason}              """), -            footer=f"ID {response_object['infraction']['id']}" +            footer=f"ID {infraction['id']}"          )      # endregion @@ -658,7 +600,7 @@ class Moderation(Scheduler):      @with_role(*MODERATION_ROLES)      @command(hidden=True, aliases=["shadowtempmute, stempmute"])      async def shadow_tempmute( -        self, ctx: Context, user: Member, duration: str, *, reason: str = None +        self, ctx: Context, user: Member, duration: ExpirationDate, *, reason: str = None      ) -> None:          """          Create a temporary mute infraction for a user with the provided reason. @@ -667,19 +609,25 @@ class Moderation(Scheduler):          This does not send the user a notification.          """ -        response_object = await post_infraction( -            ctx, user, type="mute", reason=reason, duration=duration, hidden=True -        ) -        if response_object is None: +        expiration = duration + +        if await already_has_active_infraction(ctx=ctx, user=user, type="mute"): +            return + +        infraction = await post_infraction(ctx, user, type="mute", reason=reason, expires_at=expiration, hidden=True) +        if infraction is None:              return          self.mod_log.ignore(Event.member_update, user.id)          await user.add_roles(self._muted_role, reason=reason) -        infraction_object = response_object["infraction"] -        infraction_expiration = infraction_object["expires_at"] +        infraction_expiration = ( +            datetime +            .fromisoformat(infraction["expires_at"][:-1]) +            .strftime('%c') +        ) -        self.schedule_expiration(ctx.bot.loop, infraction_object) +        self.schedule_task(ctx.bot.loop, infraction["id"], infraction)          if reason is None:              await ctx.send(f":ok_hand: muted {user.mention} until {infraction_expiration}.") @@ -697,16 +645,15 @@ class Moderation(Scheduler):                  Member: {user.mention} (`{user.id}`)                  Actor: {ctx.message.author}                  Reason: {reason} -                Duration: {duration}                  Expires: {infraction_expiration}              """), -            footer=f"ID {response_object['infraction']['id']}" +            footer=f"ID {infraction['id']}"          )      @with_role(*MODERATION_ROLES)      @command(hidden=True, aliases=["shadowtempban, stempban"])      async def shadow_tempban( -            self, ctx: Context, user: UserTypes, duration: str, *, reason: str = None +        self, ctx: Context, user: UserTypes, duration: ExpirationDate, *, reason: str = None      ) -> None:          """          Create a temporary ban infraction for a user with the provided reason. @@ -715,15 +662,18 @@ class Moderation(Scheduler):          This does not send the user a notification.          """ +        expiration = duration +          if not await self.respect_role_hierarchy(ctx, user, 'shadowtempban'):              # Ensure ctx author has a higher top role than the target user              # Warning is sent to ctx by the helper method              return -        response_object = await post_infraction( -            ctx, user, type="ban", reason=reason, duration=duration, hidden=True -        ) -        if response_object is None: +        if await already_has_active_infraction(ctx=ctx, user=user, type="ban"): +            return + +        infraction = await post_infraction(ctx, user, type="ban", reason=reason, expires_at=expiration, hidden=True) +        if infraction is None:              return          self.mod_log.ignore(Event.member_ban, user.id) @@ -735,10 +685,13 @@ class Moderation(Scheduler):          except Forbidden:              action_result = False -        infraction_object = response_object["infraction"] -        infraction_expiration = infraction_object["expires_at"] +        infraction_expiration = ( +            datetime +            .fromisoformat(infraction["expires_at"][:-1]) +            .strftime('%c') +        ) -        self.schedule_expiration(ctx.bot.loop, infraction_object) +        self.schedule_task(ctx.bot.loop, infraction["id"], infraction)          if reason is None:              await ctx.send(f":ok_hand: banned {user.mention} until {infraction_expiration}.") @@ -764,11 +717,10 @@ class Moderation(Scheduler):                  Member: {user.mention} (`{user.id}`)                  Actor: {ctx.message.author}                  Reason: {reason} -                Duration: {duration}                  Expires: {infraction_expiration}              """),              content=log_content, -            footer=f"ID {response_object['infraction']['id']}" +            footer=f"ID {infraction['id']}"          )      # endregion @@ -776,7 +728,7 @@ class Moderation(Scheduler):      @with_role(*MODERATION_ROLES)      @command() -    async def unmute(self, ctx: Context, user: Member) -> None: +    async def unmute(self, ctx: Context, user: UserTypes) -> None:          """Deactivates the active mute infraction for a user."""          try:              # check the current active infraction @@ -793,14 +745,15 @@ class Moderation(Scheduler):              if not response:                  # no active infraction -                return await ctx.send( +                await ctx.send(                      f":x: There is no active mute infraction for user {user.mention}."                  ) +                return -            infraction = response[0] -            await self._deactivate_infraction(infraction) -            if infraction["expires_at"] is not None: -                self.cancel_expiration(infraction["id"]) +            for infraction in response: +                await self._deactivate_infraction(infraction) +                if infraction["expires_at"] is not None: +                    self.cancel_expiration(infraction["id"])              notified = await self.notify_pardon(                  user=user, @@ -820,19 +773,31 @@ class Moderation(Scheduler):              await ctx.send(f"{dm_emoji}:ok_hand: Un-muted {user.mention}.") +            embed_text = textwrap.dedent( +                f""" +                    Member: {user.mention} (`{user.id}`) +                    Actor: {ctx.message.author} +                    DM: {dm_status} +                """ +            ) + +            if len(response) > 1: +                footer = f"Infraction IDs: {', '.join(str(infr['id']) for infr in response)}" +                title = "Member unmuted" +                embed_text += "Note: User had multiple **active** mute infractions in the database." +            else: +                infraction = response[0] +                footer = f"Infraction ID: {infraction['id']}" +                title = "Member unmuted" +              # Send a log message to the mod log              await self.mod_log.send_log_message(                  icon_url=Icons.user_unmute,                  colour=Colour(Colours.soft_green), -                title="Member unmuted", +                title=title,                  thumbnail=user.avatar_url_as(static_format="png"), -                text=textwrap.dedent(f""" -                    Member: {user.mention} (`{user.id}`) -                    Actor: {ctx.message.author} -                    Intended expiry: {infraction['expires_at']} -                    DM: {dm_status} -                """), -                footer=infraction["id"], +                text=embed_text, +                footer=footer,                  content=log_content              )          except Exception: @@ -861,14 +826,29 @@ class Moderation(Scheduler):              if not response:                  # no active infraction -                return await ctx.send( +                await ctx.send(                      f":x: There is no active ban infraction for user {user.mention}."                  ) +                return -            infraction = response[0] -            await self._deactivate_infraction(infraction) -            if infraction["expires_at"] is not None: -                self.cancel_expiration(infraction["id"]) +            for infraction in response: +                await self._deactivate_infraction(infraction) +                if infraction["expires_at"] is not None: +                    self.cancel_expiration(infraction["id"]) + +            embed_text = textwrap.dedent( +                f""" +                    Member: {user.mention} (`{user.id}`) +                    Actor: {ctx.message.author} +                """ +            ) + +            if len(response) > 1: +                footer = f"Infraction IDs: {', '.join(str(infr['id']) for infr in response)}" +                embed_text += "Note: User had multiple **active** ban infractions in the database." +            else: +                infraction = response[0] +                footer = f"Infraction ID: {infraction['id']}"              await ctx.send(f":ok_hand: Un-banned {user.mention}.") @@ -878,11 +858,8 @@ class Moderation(Scheduler):                  colour=Colour(Colours.soft_green),                  title="Member unbanned",                  thumbnail=user.avatar_url_as(static_format="png"), -                text=textwrap.dedent(f""" -                    Member: {user.mention} (`{user.id}`) -                    Actor: {ctx.message.author} -                    Intended expiry: {infraction['expires_at']} -                """) +                text=embed_text, +                footer=footer,              )          except Exception:              log.exception("There was an error removing an infraction.") @@ -1008,7 +985,8 @@ class Moderation(Scheduler):          except Exception:              log.exception("There was an error updating an infraction.") -            return await ctx.send(":x: There was an error updating the infraction.") +            await ctx.send(":x: There was an error updating the infraction.") +            return          # Get information about the infraction's user          user_id = updated_infraction['user'] @@ -1105,7 +1083,9 @@ class Moderation(Scheduler):      # endregion      # region: Utility functions -    def schedule_expiration(self, loop: asyncio.AbstractEventLoop, infraction_object: dict) -> None: +    def schedule_expiration( +        self, loop: asyncio.AbstractEventLoop, infraction_object: Dict[str, Union[str, int, bool]] +    ) -> None:          """Schedules a task to expire a temporary infraction."""          infraction_id = infraction_object["id"]          if infraction_id in self.scheduled_tasks: @@ -1125,7 +1105,7 @@ class Moderation(Scheduler):          log.debug(f"Unscheduled {infraction_id}.")          del self.scheduled_tasks[infraction_id] -    async def _scheduled_task(self, infraction_object: dict) -> None: +    async def _scheduled_task(self, infraction_object: Dict[str, Union[str, int, bool]]) -> None:          """          Marks an infraction expired after the delay from time of scheduling to time of expiration. @@ -1153,7 +1133,7 @@ class Moderation(Scheduler):              icon_url=Icons.user_unmute          ) -    async def _deactivate_infraction(self, infraction_object: dict) -> None: +    async def _deactivate_infraction(self, infraction_object: Dict[str, Union[str, int, bool]]) -> None:          """          A co-routine which marks an infraction as inactive on the website. @@ -1173,14 +1153,17 @@ class Moderation(Scheduler):                  log.warning(f"Failed to un-mute user: {user_id} (not found)")          elif infraction_type == "ban":              user: Object = Object(user_id) -            await guild.unban(user) +            try: +                await guild.unban(user) +            except NotFound: +                log.info(f"Tried to unban user `{user_id}`, but Discord does not have an active ban registered.")          await self.bot.api_client.patch(              'bot/infractions/' + str(infraction_object['id']),              json={"active": False}          ) -    def _infraction_to_string(self, infraction_object: dict) -> str: +    def _infraction_to_string(self, infraction_object: Dict[str, Union[str, int, bool]]) -> str:          """Convert the infraction object to a string representation."""          actor_id = infraction_object["actor"]          guild: Guild = self.bot.get_guild(constants.Guild.id) @@ -1188,6 +1171,11 @@ class Moderation(Scheduler):          active = infraction_object["active"]          user_id = infraction_object["user"]          hidden = infraction_object["hidden"] +        created = datetime.fromisoformat(infraction_object["inserted_at"]).strftime("%Y-%m-%d %H:%M") +        if infraction_object["expires_at"] is None: +            expires = "*Permanent*" +        else: +            expires = datetime.fromisoformat(infraction_object["expires_at"]).strftime("%Y-%m-%d %H:%M")          lines = textwrap.dedent(f"""              {"**===============**" if active else "==============="} @@ -1196,8 +1184,8 @@ class Moderation(Scheduler):              Type: **{infraction_object["type"]}**              Shadow: {hidden}              Reason: {infraction_object["reason"] or "*None*"} -            Created: {infraction_object["inserted_at"]} -            Expires: {infraction_object["expires_at"] or "*Permanent*"} +            Created: {created} +            Expires: {expires}              Actor: {actor.mention if actor else actor_id}              ID: `{infraction_object["id"]}`              {"**===============**" if active else "==============="} @@ -1206,13 +1194,16 @@ class Moderation(Scheduler):          return lines.strip()      async def notify_infraction( -            self, user: Union[User, Member], infr_type: str, -            expires_at: Union[datetime, str] = 'N/A', reason: str = "No reason provided." +            self, +            user: Union[User, Member], +            infr_type: str, +            expires_at: Union[datetime, str] = 'N/A', +            reason: str = "No reason provided."      ) -> bool:          """          Attempt to notify a user, via DM, of their fresh infraction. -        Optionally returns a boolean indicator of whether the DM was successful. +        Returns a boolean indicator of whether the DM was successful.          """          if isinstance(expires_at, datetime):              expires_at = expires_at.strftime('%c') @@ -1237,8 +1228,11 @@ class Moderation(Scheduler):          return await self.send_private_embed(user, embed)      async def notify_pardon( -            self, user: Union[User, Member], title: str, content: str, -            icon_url: str = Icons.user_verified +        self, +        user: Union[User, Member], +        title: str, +        content: str, +        icon_url: str = Icons.user_verified      ) -> bool:          """          Attempt to notify a user, via DM, of their expired infraction. @@ -1261,7 +1255,7 @@ class Moderation(Scheduler):          Returns a boolean indicator of DM success.          """          # sometimes `user` is a `discord.Object`, so let's make it a proper user. -        user = await self.bot.get_user_info(user.id) +        user = await self.bot.fetch_user(user.id)          try:              await user.send(embed=embed) @@ -1288,13 +1282,16 @@ class Moderation(Scheduler):      # endregion -    async def __error(self, ctx: Context, error: Exception) -> None: +    @staticmethod +    async def cog_command_error(ctx: Context, error: Exception) -> None:          """Send a notification to the invoking context on a Union failure."""          if isinstance(error, BadUnionArgument):              if User in error.converters:                  await ctx.send(str(error.errors[0])) +                error.handled = True -    async def respect_role_hierarchy(self, ctx: Context, target: UserTypes, infr_type: str) -> bool: +    @staticmethod +    async def respect_role_hierarchy(ctx: Context, target: UserTypes, infr_type: str) -> bool:          """          Check if the highest role of the invoking member is greater than that of the target member. diff --git a/bot/cogs/modlog.py b/bot/cogs/modlog.py index d3ff406d8..d76804c34 100644 --- a/bot/cogs/modlog.py +++ b/bot/cogs/modlog.py @@ -11,7 +11,7 @@ from discord import (      RawMessageUpdateEvent, Role, TextChannel, User, VoiceChannel  )  from discord.abc import GuildChannel -from discord.ext.commands import Bot +from discord.ext.commands import Bot, Cog  from bot.constants import (      Channels, Colours, Emojis, Event, Guild as GuildConstant, Icons, URLs @@ -24,11 +24,11 @@ GUILD_CHANNEL = Union[CategoryChannel, TextChannel, VoiceChannel]  CHANNEL_CHANGES_UNSUPPORTED = ("permissions",)  CHANNEL_CHANGES_SUPPRESSED = ("_overwrites", "position") -MEMBER_CHANGES_SUPPRESSED = ("activity", "status") +MEMBER_CHANGES_SUPPRESSED = ("status", "activities", "_client_status")  ROLE_CHANGES_UNSUPPORTED = ("colour", "permissions") -class ModLog: +class ModLog(Cog, name="ModLog"):      """Logging for server events and staff actions."""      def __init__(self, bot: Bot): @@ -87,7 +87,7 @@ class ModLog:              additional_embeds_msg: Optional[str] = None,              timestamp_override: Optional[datetime] = None,              footer: Optional[str] = None, -    ) -> None: +    ) -> Optional[Message]:          """Generate log embed and send to logging channel."""          embed = Embed(description=text) @@ -120,6 +120,7 @@ class ModLog:          return await self.bot.get_context(log_message)  # Optionally return for use with antispam +    @Cog.listener()      async def on_guild_channel_create(self, channel: GUILD_CHANNEL) -> None:          """Log channel create event to mod log."""          if channel.guild.id != GuildConstant.id: @@ -145,6 +146,7 @@ class ModLog:          await self.send_log_message(Icons.hash_green, Colour(Colours.soft_green), title, message) +    @Cog.listener()      async def on_guild_channel_delete(self, channel: GUILD_CHANNEL) -> None:          """Log channel delete event to mod log."""          if channel.guild.id != GuildConstant.id: @@ -167,6 +169,7 @@ class ModLog:              title, message          ) +    @Cog.listener()      async def on_guild_channel_update(self, before: GUILD_CHANNEL, after: GuildChannel) -> None:          """Log channel update event to mod log."""          if before.guild.id != GuildConstant.id: @@ -226,6 +229,7 @@ class ModLog:              "Channel updated", message          ) +    @Cog.listener()      async def on_guild_role_create(self, role: Role) -> None:          """Log role create event to mod log."""          if role.guild.id != GuildConstant.id: @@ -236,6 +240,7 @@ class ModLog:              "Role created", f"`{role.id}`"          ) +    @Cog.listener()      async def on_guild_role_delete(self, role: Role) -> None:          """Log role delete event to mod log."""          if role.guild.id != GuildConstant.id: @@ -246,6 +251,7 @@ class ModLog:              "Role removed", f"{role.name} (`{role.id}`)"          ) +    @Cog.listener()      async def on_guild_role_update(self, before: Role, after: Role) -> None:          """Log role update event to mod log."""          if before.guild.id != GuildConstant.id: @@ -298,6 +304,7 @@ class ModLog:              "Role updated", message          ) +    @Cog.listener()      async def on_guild_update(self, before: Guild, after: Guild) -> None:          """Log guild update event to mod log."""          if before.id != GuildConstant.id: @@ -348,6 +355,7 @@ class ModLog:              thumbnail=after.icon_url_as(format="png")          ) +    @Cog.listener()      async def on_member_ban(self, guild: Guild, member: Union[Member, User]) -> None:          """Log ban event to mod log."""          if guild.id != GuildConstant.id: @@ -364,6 +372,7 @@ class ModLog:              channel_id=Channels.modlog          ) +    @Cog.listener()      async def on_member_join(self, member: Member) -> None:          """Log member join event to user log."""          if member.guild.id != GuildConstant.id: @@ -385,6 +394,7 @@ class ModLog:              channel_id=Channels.userlog          ) +    @Cog.listener()      async def on_member_remove(self, member: Member) -> None:          """Log member leave event to user log."""          if member.guild.id != GuildConstant.id: @@ -401,6 +411,7 @@ class ModLog:              channel_id=Channels.userlog          ) +    @Cog.listener()      async def on_member_unban(self, guild: Guild, member: User) -> None:          """Log member unban event to mod log."""          if guild.id != GuildConstant.id: @@ -417,6 +428,7 @@ class ModLog:              channel_id=Channels.modlog          ) +    @Cog.listener()      async def on_member_update(self, before: Member, after: Member) -> None:          """Log member update event to user log."""          if before.guild.id != GuildConstant.id: @@ -507,6 +519,7 @@ class ModLog:              channel_id=Channels.userlog          ) +    @Cog.listener()      async def on_message_delete(self, message: Message) -> None:          """Log message delete event to message change log."""          channel = message.channel @@ -539,19 +552,22 @@ class ModLog:                  "\n"              ) +        if message.attachments: +            # Prepend the message metadata with the number of attachments +            response = f"**Attachments:** {len(message.attachments)}\n" + response +          # Shorten the message content if necessary          content = message.clean_content          remaining_chars = 2040 - len(response)          if len(content) > remaining_chars: -            content = content[:remaining_chars] + "..." +            botlog_url = await self.upload_log(messages=[message], actor_id=message.author.id) +            ending = f"\n\nMessage truncated, [full message here]({botlog_url})." +            truncation_point = remaining_chars - len(ending) +            content = f"{content[:truncation_point]}...{ending}"          response += f"{content}" -        if message.attachments: -            # Prepend the message metadata with the number of attachments -            response = f"**Attachments:** {len(message.attachments)}\n" + response -          await self.send_log_message(              Icons.message_delete, Colours.soft_red,              "Message deleted", @@ -559,6 +575,7 @@ class ModLog:              channel_id=Channels.message_log          ) +    @Cog.listener()      async def on_raw_message_delete(self, event: RawMessageDeleteEvent) -> None:          """Log raw message delete event to message change log."""          if event.guild_id != GuildConstant.id or event.channel_id in GuildConstant.ignored: @@ -599,6 +616,7 @@ class ModLog:              channel_id=Channels.message_log          ) +    @Cog.listener()      async def on_message_edit(self, before: Message, after: Message) -> None:          """Log message edit event to message change log."""          if ( @@ -673,11 +691,12 @@ class ModLog:              channel_id=Channels.message_log, timestamp_override=after.edited_at          ) +    @Cog.listener()      async def on_raw_message_edit(self, event: RawMessageUpdateEvent) -> None:          """Log raw message edit event to message change log."""          try:              channel = self.bot.get_channel(int(event.data["channel_id"])) -            message = await channel.get_message(event.message_id) +            message = await channel.fetch_message(event.message_id)          except NotFound:  # Was deleted before we got the event              return diff --git a/bot/cogs/off_topic_names.py b/bot/cogs/off_topic_names.py index 3849d3d59..a81b783d6 100644 --- a/bot/cogs/off_topic_names.py +++ b/bot/cogs/off_topic_names.py @@ -1,11 +1,12 @@  import asyncio +import difflib  import logging  from datetime import datetime, timedelta  from discord import Colour, Embed -from discord.ext.commands import BadArgument, Bot, Context, Converter, group +from discord.ext.commands import BadArgument, Bot, Cog, Context, Converter, group -from bot.constants import Channels, Keys, MODERATION_ROLES +from bot.constants import Channels, MODERATION_ROLES  from bot.decorators import with_role  from bot.pagination import LinePaginator @@ -38,7 +39,7 @@ class OffTopicName(Converter):          return argument.translate(table) -async def update_names(bot: Bot, headers: dict) -> None: +async def update_names(bot: Bot) -> None:      """      The background updater task that performs a channel name update daily. @@ -69,23 +70,23 @@ async def update_names(bot: Bot, headers: dict) -> None:          ) -class OffTopicNames: +class OffTopicNames(Cog):      """Commands related to managing the off-topic category channel names."""      def __init__(self, bot: Bot):          self.bot = bot -        self.headers = {"X-API-KEY": Keys.site_api}          self.updater_task = None -    def __cleanup(self) -> None: -        """Cancel any leftover running updater task.""" +    def cog_unload(self) -> None: +        """Cancel any running updater tasks on cog unload."""          if self.updater_task is not None:              self.updater_task.cancel() +    @Cog.listener()      async def on_ready(self) -> None:          """Start off-topic channel updating event loop if it hasn't already started."""          if self.updater_task is None: -            coro = update_names(self.bot, self.headers) +            coro = update_names(self.bot)              self.updater_task = self.bot.loop.create_task(coro)      @group(name='otname', aliases=('otnames', 'otn'), invoke_without_command=True) @@ -96,25 +97,31 @@ class OffTopicNames:      @otname_group.command(name='add', aliases=('a',))      @with_role(*MODERATION_ROLES) -    async def add_command(self, ctx: Context, name: OffTopicName) -> None: +    async def add_command(self, ctx: Context, *names: OffTopicName) -> None:          """Adds a new off-topic name to the rotation.""" +        # Chain multiple words to a single one +        name = "-".join(names) +          await self.bot.api_client.post(f'bot/off-topic-channel-names', params={'name': name})          log.info(              f"{ctx.author.name}#{ctx.author.discriminator}"              f" added the off-topic channel name '{name}"          ) -        await ctx.send(":ok_hand:") +        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) -    async def delete_command(self, ctx: Context, name: OffTopicName) -> None: +    async def delete_command(self, ctx: Context, *names: OffTopicName) -> None:          """Removes a off-topic name from the rotation.""" +        # Chain multiple words to a single one +        name = "-".join(names) +          await self.bot.api_client.delete(f'bot/off-topic-channel-names/{name}')          log.info(              f"{ctx.author.name}#{ctx.author.discriminator}"              f" deleted the off-topic channel name '{name}"          ) -        await ctx.send(":ok_hand:") +        await ctx.send(f":ok_hand: Removed `{name}` from the names list.")      @otname_group.command(name='list', aliases=('l',))      @with_role(*MODERATION_ROLES) @@ -136,6 +143,25 @@ class OffTopicNames:              embed.description = "Hmmm, seems like there's nothing here yet."              await ctx.send(embed=embed) +    @otname_group.command(name='search', aliases=('s',)) +    @with_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') +        in_matches = {name for name in result if query in name} +        close_matches = difflib.get_close_matches(query, result, n=10, cutoff=0.70) +        lines = sorted(f"• {name}" for name in in_matches.union(close_matches)) +        embed = Embed( +            title=f"Query results", +            colour=Colour.blue() +        ) + +        if lines: +            await LinePaginator.paginate(lines, ctx, embed, max_size=400, empty=False) +        else: +            embed.description = "Nothing found." +            await ctx.send(embed=embed) +  def setup(bot: Bot) -> None:      """Off topic names cog load.""" diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py index 69d4adf76..fa66660e2 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -5,7 +5,7 @@ import textwrap  from datetime import datetime, timedelta  from discord import Colour, Embed, TextChannel -from discord.ext.commands import Bot, Context, group +from discord.ext.commands import Bot, Cog, Context, group  from bot.constants import Channels, ERROR_REPLIES, Reddit as RedditConfig, STAFF_ROLES  from bot.converters import Subreddit @@ -15,7 +15,7 @@ from bot.pagination import LinePaginator  log = logging.getLogger(__name__) -class Reddit: +class Reddit(Cog):      """Track subreddit posts and show detailed statistics about them."""      HEADERS = {"User-Agent": "Discord Bot: PythonDiscord (https://pythondiscord.com/)"} @@ -252,9 +252,10 @@ class Reddit:              max_lines=15          ) +    @Cog.listener()      async def on_ready(self) -> None:          """Initiate reddit post event loop.""" -        self.reddit_channel = self.bot.get_channel(Channels.reddit) +        self.reddit_channel = await self.bot.fetch_channel(Channels.reddit)          if self.reddit_channel is not None:              if self.new_posts_task is None: diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index d9f6f6536..ff0a6eb1a 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -7,7 +7,7 @@ from operator import itemgetter  from dateutil.relativedelta import relativedelta  from discord import Colour, Embed -from discord.ext.commands import Bot, Context, group +from discord.ext.commands import Bot, Cog, Context, group  from bot.constants import Channels, Icons, NEGATIVE_REPLIES, POSITIVE_REPLIES, STAFF_ROLES  from bot.converters import ExpirationDate @@ -22,13 +22,14 @@ WHITELISTED_CHANNELS = (Channels.bot,)  MAXIMUM_REMINDERS = 5 -class Reminders(Scheduler): +class Reminders(Scheduler, Cog):      """Provide in-channel reminder functionality."""      def __init__(self, bot: Bot):          self.bot = bot          super().__init__() +    @Cog.listener()      async def on_ready(self) -> None:          """Reschedule all current reminders."""          response = await self.bot.api_client.get( diff --git a/bot/cogs/security.py b/bot/cogs/security.py index 7ada9a4f6..c2c6356d3 100644 --- a/bot/cogs/security.py +++ b/bot/cogs/security.py @@ -1,11 +1,12 @@  import logging +from typing import Union -from discord.ext.commands import Bot, Context +from discord.ext.commands import Bot, Cog, Context, NoPrivateMessage  log = logging.getLogger(__name__) -class Security: +class Security(Cog):      """Security-related helpers."""      def __init__(self, bot: Bot): @@ -17,9 +18,11 @@ class Security:          """Check if Context instance author is not a bot."""          return not ctx.author.bot -    def check_on_guild(self, ctx: Context) -> bool: +    def check_on_guild(self, ctx: Context) -> Union[bool, None]:          """Check if Context instance has a guild attribute.""" -        return ctx.guild is not None +        if ctx.guild is None: +            raise NoPrivateMessage("This command cannot be used in private messages.") +        return True  def setup(bot: Bot) -> None: diff --git a/bot/cogs/site.py b/bot/cogs/site.py index a941f27a7..aadc9c632 100644 --- a/bot/cogs/site.py +++ b/bot/cogs/site.py @@ -1,7 +1,7 @@  import logging  from discord import Colour, Embed -from discord.ext.commands import Bot, Context, group +from discord.ext.commands import Bot, Cog, Context, group  from bot.constants import Channels, STAFF_ROLES, URLs  from bot.decorators import redirect_output @@ -9,10 +9,10 @@ from bot.pagination import LinePaginator  log = logging.getLogger(__name__) -INFO_URL = f"{URLs.site_schema}{URLs.site}/info" +PAGES_URL = f"{URLs.site_schema}{URLs.site}/pages" -class Site: +class Site(Cog):      """Commands for linking to different parts of the site."""      def __init__(self, bot: Bot): @@ -43,15 +43,18 @@ class Site:      @site_group.command(name="resources")      async def site_resources(self, ctx: Context) -> None:          """Info about the site's Resources page.""" -        url = f"{INFO_URL}/resources" +        learning_url = f"{PAGES_URL}/resources" +        tools_url = f"{PAGES_URL}/tools" -        embed = Embed(title="Resources") -        embed.set_footer(text=url) +        embed = Embed(title="Resources & Tools") +        embed.set_footer(text=f"{learning_url} | {tools_url}")          embed.colour = Colour.blurple()          embed.description = ( -            f"The [Resources page]({url}) on our website contains a " +            f"The [Resources page]({learning_url}) on our website contains a "              "list of hand-selected goodies that we regularly recommend " -            "to both beginners and experts." +            f"to both beginners and experts. The [Tools page]({tools_url}) " +            "contains a couple of the most popular tools for programming in " +            "Python."          )          await ctx.send(embed=embed) @@ -59,9 +62,9 @@ class Site:      @site_group.command(name="help")      async def site_help(self, ctx: Context) -> None:          """Info about the site's Getting Help page.""" -        url = f"{INFO_URL}/help" +        url = f"{PAGES_URL}/asking-good-questions" -        embed = Embed(title="Getting Help") +        embed = Embed(title="Asking Good Questions")          embed.set_footer(text=url)          embed.colour = Colour.blurple()          embed.description = ( @@ -75,7 +78,7 @@ class Site:      @site_group.command(name="faq")      async def site_faq(self, ctx: Context) -> None:          """Info about the site's FAQ page.""" -        url = f"{INFO_URL}/faq" +        url = f"{PAGES_URL}/frequently-asked-questions"          embed = Embed(title="FAQ")          embed.set_footer(text=url) @@ -94,13 +97,13 @@ class Site:      async def site_rules(self, ctx: Context, *rules: int) -> None:          """Provides a link to the `rules` endpoint of the website, or displays specific rule(s), if requested."""          rules_embed = Embed(title='Rules', color=Colour.blurple()) -        rules_embed.url = f"{URLs.site_schema}{URLs.site}/about/rules" +        rules_embed.url = f"{PAGES_URL}/rules"          if not rules:              # Rules were not submitted. Return the default description.              rules_embed.description = (                  "The rules and guidelines that apply to this community can be found on" -                " our [rules page](https://pythondiscord.com/about/rules). We expect" +                f" our [rules page]({PAGES_URL}/rules). We expect"                  " all members of the community to have read and understood these."              ) diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index 5a5e26655..927afe51b 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -1,18 +1,14 @@  import datetime  import logging -import random  import re  import textwrap  from signal import Signals  from typing import Optional, Tuple -from discord import Colour, Embed -from discord.ext.commands import ( -    Bot, CommandError, Context, NoPrivateMessage, command, guild_only -) +from discord.ext.commands import Bot, Cog, Context, command, guild_only -from bot.constants import Channels, ERROR_REPLIES, NEGATIVE_REPLIES, STAFF_ROLES, URLs -from bot.decorators import InChannelCheckFailure, in_channel +from bot.constants import Channels, STAFF_ROLES, URLs +from bot.decorators import in_channel  from bot.utils.messages import wait_for_deletion @@ -40,7 +36,7 @@ RAW_CODE_REGEX = re.compile(  MAX_PASTE_LEN = 1000 -class Snekbox: +class Snekbox(Cog):      """Safe evaluation of Python code using Snekbox."""      def __init__(self, bot: Bot): @@ -222,28 +218,6 @@ class Snekbox:          finally:              del self.jobs[ctx.author.id] -    @eval_command.error -    async def eval_command_error(self, ctx: Context, error: CommandError) -> None: -        """Eval commands error handler.""" -        embed = Embed(colour=Colour.red()) - -        if isinstance(error, NoPrivateMessage): -            embed.title = random.choice(NEGATIVE_REPLIES) -            embed.description = "You're not allowed to use this command in private messages." -            await ctx.send(embed=embed) - -        elif isinstance(error, InChannelCheckFailure): -            embed.title = random.choice(NEGATIVE_REPLIES) -            embed.description = str(error) -            await ctx.send(embed=embed) - -        else: -            original_error = getattr(error, 'original', "no original error") -            log.error(f"Unhandled error in snekbox eval: {error} ({original_error})") -            embed.title = random.choice(ERROR_REPLIES) -            embed.description = "Some unhandled error occurred. Sorry for that!" -            await ctx.send(embed=embed) -  def setup(bot: Bot) -> None:      """Snekbox cog load.""" diff --git a/bot/cogs/superstarify/__init__.py b/bot/cogs/superstarify/__init__.py index bd5211102..7a29acdb8 100644 --- a/bot/cogs/superstarify/__init__.py +++ b/bot/cogs/superstarify/__init__.py @@ -4,7 +4,7 @@ from datetime import datetime  from discord import Colour, Embed, Member  from discord.errors import Forbidden -from discord.ext.commands import Bot, Context, command +from discord.ext.commands import Bot, Cog, Context, command  from bot.cogs.moderation import Moderation  from bot.cogs.modlog import ModLog @@ -15,10 +15,10 @@ from bot.decorators import with_role  from bot.utils.moderation import post_infraction  log = logging.getLogger(__name__) -NICKNAME_POLICY_URL = "https://pythondiscord.com/about/rules#nickname-policy" +NICKNAME_POLICY_URL = "https://pythondiscord.com/pages/rules/#wiki-toc-nickname-policy" -class Superstarify: +class Superstarify(Cog):      """A set of commands to moderate terrible nicknames."""      def __init__(self, bot: Bot): @@ -34,6 +34,7 @@ class Superstarify:          """Get currently loaded ModLog cog instance."""          return self.bot.get_cog("ModLog") +    @Cog.listener()      async def on_member_update(self, before: Member, after: Member) -> None:          """          This event will trigger when someone changes their name. @@ -91,6 +92,7 @@ class Superstarify:                      "to DM them, and a discord.errors.Forbidden error was incurred."                  ) +    @Cog.listener()      async def on_member_join(self, member: Member) -> None:          """          This event will trigger when someone (re)joins the server. @@ -102,7 +104,7 @@ class Superstarify:              'bot/infractions',              params={                  'active': 'true', -                'type': 'superstarify', +                'type': 'superstar',                  'user__id': member.id              }          ) diff --git a/bot/cogs/sync/cog.py b/bot/cogs/sync/cog.py index 928ffa418..b75fb26cd 100644 --- a/bot/cogs/sync/cog.py +++ b/bot/cogs/sync/cog.py @@ -3,7 +3,7 @@ from typing import Callable, Iterable  from discord import Guild, Member, Role  from discord.ext import commands -from discord.ext.commands import Bot, Context +from discord.ext.commands import Bot, Cog, Context  from bot import constants  from bot.api import ResponseCodeError @@ -12,7 +12,7 @@ from bot.cogs.sync import syncers  log = logging.getLogger(__name__) -class Sync: +class Sync(Cog):      """Captures relevant events and sends them to the site."""      # The server to synchronize events on. @@ -29,6 +29,7 @@ class Sync:      def __init__(self, bot: Bot) -> None:          self.bot = bot +    @Cog.listener()      async def on_ready(self) -> None:          """Syncs the roles/users of the guild with the database."""          guild = self.bot.get_guild(self.SYNC_SERVER_ID) @@ -47,6 +48,7 @@ class Sync:                          f"deleted `{total_deleted}`."                      ) +    @Cog.listener()      async def on_guild_role_create(self, role: Role) -> None:          """Adds newly create role to the database table over the API."""          await self.bot.api_client.post( @@ -60,10 +62,12 @@ class Sync:              }          ) +    @Cog.listener()      async def on_guild_role_delete(self, role: Role) -> None:          """Deletes role from the database when it's deleted from the guild."""          await self.bot.api_client.delete(f'bot/roles/{role.id}') +    @Cog.listener()      async def on_guild_role_update(self, before: Role, after: Role) -> None:          """Syncs role with the database if any of the stored attributes were updated."""          if ( @@ -83,6 +87,7 @@ class Sync:                  }              ) +    @Cog.listener()      async def on_member_join(self, member: Member) -> None:          """          Adds a new user or updates existing user to the database when a member joins the guild. @@ -118,7 +123,8 @@ class Sync:              # If we got `404`, the user is new. Create them.              await self.bot.api_client.post('bot/users', json=packed) -    async def on_member_leave(self, member: Member) -> None: +    @Cog.listener() +    async def on_member_remove(self, member: Member) -> None:          """Updates the user information when a member leaves the guild."""          await self.bot.api_client.put(              f'bot/users/{member.id}', @@ -132,6 +138,7 @@ class Sync:              }          ) +    @Cog.listener()      async def on_member_update(self, before: Member, after: Member) -> None:          """Updates the user information if any of relevant attributes have changed."""          if ( diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index 3a93f0d47..660620284 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -2,9 +2,9 @@ import logging  import time  from discord import Colour, Embed -from discord.ext.commands import Bot, Context, group +from discord.ext.commands import Bot, Cog, Context, group -from bot.constants import Channels, Cooldowns, Keys, MODERATION_ROLES, Roles +from bot.constants import Channels, Cooldowns, MODERATION_ROLES, Roles  from bot.converters import TagContentConverter, TagNameConverter  from bot.decorators import with_role  from bot.pagination import LinePaginator @@ -19,13 +19,12 @@ TEST_CHANNELS = (  ) -class Tags: +class Tags(Cog):      """Save new tags and fetch existing tags."""      def __init__(self, bot: Bot):          self.bot = bot          self.tag_cooldowns = {} -        self.headers = {"Authorization": f"Token {Keys.site_api}"}      @group(name='tags', aliases=('tag', 't'), hidden=True, invoke_without_command=True)      async def tags_group(self, ctx: Context, *, tag_name: TagNameConverter = None) -> None: @@ -67,7 +66,7 @@ class Tags:                      "time": time.time(),                      "channel": ctx.channel.id                  } -            await ctx.send(embed=Embed.from_data(tag['embed'])) +            await ctx.send(embed=Embed.from_dict(tag['embed']))          else:              tags = await self.bot.api_client.get('bot/tags') diff --git a/bot/cogs/token_remover.py b/bot/cogs/token_remover.py index 7e9f5ef84..7dd0afbbd 100644 --- a/bot/cogs/token_remover.py +++ b/bot/cogs/token_remover.py @@ -6,7 +6,7 @@ import struct  from datetime import datetime  from discord import Colour, Message -from discord.ext.commands import Bot +from discord.ext.commands import Bot, Cog  from discord.utils import snowflake_time  from bot.cogs.modlog import ModLog @@ -26,17 +26,15 @@ DELETION_MESSAGE_TEMPLATE = (  DISCORD_EPOCH_TIMESTAMP = datetime(2017, 1, 1)  TOKEN_EPOCH = 1_293_840_000  TOKEN_RE = re.compile( -    r"(?<=(\"|'))"  # Lookbehind: Only match if there's a double or single quote in front      r"[^\s\.]+"     # Matches token part 1: The user ID string, encoded as base64      r"\."           # Matches a literal dot between the token parts      r"[^\s\.]+"     # Matches token part 2: The creation timestamp, as an integer      r"\."           # Matches a literal dot between the token parts      r"[^\s\.]+"     # Matches token part 3: The HMAC, unused by us, but check that it isn't empty -    r"(?=(\"|'))"   # Lookahead: Only match if there's a double or single quote after  ) -class TokenRemover: +class TokenRemover(Cog):      """Scans messages for potential discord.py bot tokens and removes them."""      def __init__(self, bot: Bot): @@ -47,6 +45,7 @@ class TokenRemover:          """Get currently loaded ModLog cog instance."""          return self.bot.get_cog("ModLog") +    @Cog.listener()      async def on_message(self, msg: Message) -> None:          """          Check each message for a string that matches Discord's token pattern. diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index 09e8f70d6..1539851ea 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -1,20 +1,19 @@  import logging -import random  import re  import unicodedata  from email.parser import HeaderParser  from io import StringIO  from discord import Colour, Embed -from discord.ext.commands import Bot, CheckFailure, Context, command +from discord.ext.commands import Bot, Cog, Context, command -from bot.constants import Channels, NEGATIVE_REPLIES, STAFF_ROLES -from bot.decorators import InChannelCheckFailure, in_channel +from bot.constants import Channels, STAFF_ROLES +from bot.decorators import in_channel  log = logging.getLogger(__name__) -class Utils: +class Utils(Cog):      """A selection of utilities which don't have a clear category."""      def __init__(self, bot: Bot): @@ -125,14 +124,6 @@ class Utils:          await ctx.send(embed=embed) -    async def __error(self, ctx: Context, error: CheckFailure) -> None: -        """Send Check failure error to invoking context if command is invoked in a blacklisted channel by non-staff.""" -        embed = Embed(colour=Colour.red()) -        if isinstance(error, InChannelCheckFailure): -            embed.title = random.choice(NEGATIVE_REPLIES) -            embed.description = str(error) -            await ctx.send(embed=embed) -  def setup(bot: Bot) -> None:      """Utils cog load.""" diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 2cc372afe..6446dc80b 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -1,11 +1,11 @@  import logging  from discord import Message, NotFound, Object -from discord.ext.commands import Bot, Context, command +from discord.ext.commands import Bot, Cog, Context, command  from bot.cogs.modlog import ModLog  from bot.constants import Channels, Event, Roles -from bot.decorators import in_channel, without_role +from bot.decorators import InChannelCheckFailure, in_channel, without_role  log = logging.getLogger(__name__) @@ -14,8 +14,8 @@ Hello! Welcome to the server, and thanks for verifying yourself!  For your records, these are the documents you accepted: -`1)` Our rules, here: <https://pythondiscord.com/about/rules> -`2)` Our privacy policy, here: <https://pythondiscord.com/about/privacy> - you can find information on how to have \ +`1)` Our rules, here: <https://pythondiscord.com/pages/rules> +`2)` Our privacy policy, here: <https://pythondiscord.com/pages/privacy> - you can find information on how to have \  your information removed here as well.  Feel free to review them at any point! @@ -28,7 +28,7 @@ If you'd like to unsubscribe from the announcement notifications, simply send `!  """ -class Verification: +class Verification(Cog):      """User verification and role self-management."""      def __init__(self, bot: Bot): @@ -39,6 +39,7 @@ class Verification:          """Get currently loaded ModLog cog instance."""          return self.bot.get_cog("ModLog") +    @Cog.listener()      async def on_message(self, message: Message) -> None:          """Check new message event for messages to the checkpoint channel & process."""          if message.author.bot: @@ -143,7 +144,14 @@ class Verification:          )      @staticmethod -    def __global_check(ctx: Context) -> None: +    async def cog_command_error(ctx: Context, error: Exception) -> None: +        """Check for & ignore any InChannelCheckFailure.""" +        if isinstance(error, InChannelCheckFailure): +            # Do nothing; just ignore this error +            error.handled = True + +    @staticmethod +    def bot_check(ctx: Context) -> bool:          """Block any command within the verification channel that is not !accept."""          if ctx.channel.id == Channels.verification:              return ctx.command.name == "accept" diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/cogs/watchchannels/bigbrother.py index a4c95d8ad..e191c2dbc 100644 --- a/bot/cogs/watchchannels/bigbrother.py +++ b/bot/cogs/watchchannels/bigbrother.py @@ -3,7 +3,7 @@ from collections import ChainMap  from typing import Union  from discord import User -from discord.ext.commands import Bot, Context, group +from discord.ext.commands import Bot, Cog, Context, group  from bot.constants import Channels, Roles, Webhooks  from bot.decorators import with_role @@ -13,7 +13,7 @@ from .watchchannel import WatchChannel, proxy_user  log = logging.getLogger(__name__) -class BigBrother(WatchChannel): +class BigBrother(WatchChannel, Cog, name="Big Brother"):      """Monitors users by relaying their messages to a watch channel to assist with moderation."""      def __init__(self, bot: Bot) -> None: diff --git a/bot/cogs/watchchannels/talentpool.py b/bot/cogs/watchchannels/talentpool.py index bea0a8b0a..ffe7693a9 100644 --- a/bot/cogs/watchchannels/talentpool.py +++ b/bot/cogs/watchchannels/talentpool.py @@ -4,7 +4,7 @@ from collections import ChainMap  from typing import Union  from discord import Color, Embed, Member, User -from discord.ext.commands import Bot, Context, group +from discord.ext.commands import Bot, Cog, Context, group  from bot.api import ResponseCodeError  from bot.constants import Channels, Guild, Roles, Webhooks @@ -16,7 +16,7 @@ log = logging.getLogger(__name__)  STAFF_ROLES = Roles.owner, Roles.admin, Roles.moderator, Roles.helpers    # <- In constants after the merge? -class TalentPool(WatchChannel): +class TalentPool(WatchChannel, Cog, name="Talentpool"):      """Relays messages of helper candidates to a watch channel to observe them."""      def __init__(self, bot: Bot) -> None: diff --git a/bot/cogs/watchchannels/watchchannel.py b/bot/cogs/watchchannels/watchchannel.py index 5ca819955..1a3f5b18c 100644 --- a/bot/cogs/watchchannels/watchchannel.py +++ b/bot/cogs/watchchannels/watchchannel.py @@ -3,20 +3,20 @@ import datetime  import logging  import re  import textwrap -from abc import ABC, abstractmethod +from abc import abstractmethod  from collections import defaultdict, deque  from dataclasses import dataclass  from typing import Optional  import discord -from discord import Color, Embed, Message, Object, errors -from discord.ext.commands import BadArgument, Bot, Context +from discord import Color, Embed, HTTPException, Message, Object, errors +from discord.ext.commands import BadArgument, Bot, Cog, Context  from bot.api import ResponseCodeError  from bot.cogs.modlog import ModLog  from bot.constants import BigBrother as BigBrotherConfig, Guild as GuildConfig, Icons  from bot.pagination import LinePaginator -from bot.utils import messages +from bot.utils import CogABCMeta, messages  from bot.utils.time import time_since  log = logging.getLogger(__name__) @@ -49,7 +49,7 @@ class MessageHistory:      message_count: int = 0 -class WatchChannel(ABC): +class WatchChannel(metaclass=CogABCMeta):      """ABC with functionality for relaying users' messages to a certain channel."""      @abstractmethod @@ -103,21 +103,14 @@ class WatchChannel(ABC):          """Starts the watch channel by getting the channel, webhook, and user cache ready."""          await self.bot.wait_until_ready() -        # After updating d.py, this block can be replaced by `fetch_channel` with a try-except -        for attempt in range(1, self.retries+1): -            self.channel = self.bot.get_channel(self.destination) -            if self.channel is None: -                if attempt < self.retries: -                    await asyncio.sleep(self.retry_delay) -            else: -                break -        else: -            self.log.error(f"Failed to retrieve the text channel with id {self.destination}") +        try: +            self.channel = await self.bot.fetch_channel(self.destination) +        except HTTPException: +            self.log.exception(f"Failed to retrieve the text channel with id `{self.destination}`") -        # `get_webhook_info` has been renamed to `fetch_webhook` in newer versions of d.py          try: -            self.webhook = await self.bot.get_webhook_info(self.webhook_id) -        except (discord.HTTPException, discord.NotFound, discord.Forbidden): +            self.webhook = await self.bot.fetch_webhook(self.webhook_id) +        except discord.HTTPException:              self.log.exception(f"Failed to fetch webhook with id `{self.webhook_id}`")          if self.channel is None or self.webhook is None: @@ -174,6 +167,7 @@ class WatchChannel(ABC):          return True +    @Cog.listener()      async def on_message(self, msg: Message) -> None:          """Queues up messages sent by watched users."""          if msg.author.id in self.watched_users: diff --git a/bot/cogs/wolfram.py b/bot/cogs/wolfram.py index 0093a1615..1aa656a4b 100644 --- a/bot/cogs/wolfram.py +++ b/bot/cogs/wolfram.py @@ -4,12 +4,14 @@ from typing import List, Optional, Tuple  from urllib import parse  import discord +from dateutil.relativedelta import relativedelta  from discord import Embed  from discord.ext import commands -from discord.ext.commands import Bot, BucketType, Context, check, group +from discord.ext.commands import Bot, BucketType, Cog, Context, check, group  from bot.constants import Colours, STAFF_ROLES, Wolfram  from bot.pagination import ImagePaginator +from bot.utils.time import humanize_delta  log = logging.getLogger(__name__) @@ -64,9 +66,11 @@ def custom_cooldown(*ignore: List[int]) -> check:              if user_rate:                  # Can't use api; cause: member limit +                delta = relativedelta(seconds=int(user_rate)) +                cooldown = humanize_delta(delta)                  message = (                      "You've used up your limit for Wolfram|Alpha requests.\n" -                    f"Cooldown: {int(user_rate)}" +                    f"Cooldown: {cooldown}"                  )                  await send_embed(ctx, message)                  return False @@ -106,17 +110,27 @@ async def get_pod_pages(ctx: Context, bot: Bot, query: str) -> Optional[List[Tup          result = json["queryresult"] -        if not result["success"]: -            message = f"I couldn't find anything for {query}." -            await send_embed(ctx, message) -            return -          if result["error"]: +            # API key not set up correctly +            if result["error"]["msg"] == "Invalid appid": +                message = "Wolfram API key is invalid or missing." +                log.warning( +                    "API key seems to be missing, or invalid when " +                    f"processing a wolfram request: {url_str}, Response: {json}" +                ) +                await send_embed(ctx, message) +                return +              message = "Something went wrong internally with your request, please notify staff!"              log.warning(f"Something went wrong getting a response from wolfram: {url_str}, Response: {json}")              await send_embed(ctx, message)              return +        if not result["success"]: +            message = f"I couldn't find anything for {query}." +            await send_embed(ctx, message) +            return +          if not result["numpods"]:              message = "Could not find any results."              await send_embed(ctx, message) @@ -134,7 +148,7 @@ async def get_pod_pages(ctx: Context, bot: Bot, query: str) -> Optional[List[Tup          return pages -class Wolfram: +class Wolfram(Cog):      """Commands for interacting with the Wolfram|Alpha API."""      def __init__(self, bot: commands.Bot): @@ -167,6 +181,10 @@ class Wolfram:                  message = "No input found"                  footer = ""                  color = Colours.soft_red +            elif status == 403: +                message = "Wolfram API key is invalid or missing." +                footer = "" +                color = Colours.soft_red              else:                  message = ""                  footer = "View original for a bigger picture." @@ -235,10 +253,12 @@ class Wolfram:              if status == 501:                  message = "Failed to get response"                  color = Colours.soft_red -              elif status == 400:                  message = "No input found"                  color = Colours.soft_red +            elif response_text == "Error 1: Invalid appid": +                message = "Wolfram API key is invalid or missing." +                color = Colours.soft_red              else:                  message = response_text                  color = Colours.soft_orange diff --git a/bot/constants.py b/bot/constants.py index ead26c91d..e1c47889c 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -341,13 +341,15 @@ class Channels(metaclass=YAMLGetter):      help_3: int      help_4: int      help_5: int +    help_6: int +    help_7: int      helpers: int      message_log: int      mod_alerts: int      modlog: int +    off_topic_0: int      off_topic_1: int      off_topic_2: int -    off_topic_3: int      python: int      reddit: int      talent_pool: int @@ -372,13 +374,12 @@ class Roles(metaclass=YAMLGetter):      announcements: int      champion: int      contributor: int -    developer: int -    devops: int +    core_developer: int      jammer: int      moderator: int      muted: int      owner: int -    verified: int +    verified: int  # This is the Developers role on PyDis, here named verified for readability reasons.      helpers: int      team_leader: int @@ -393,8 +394,6 @@ class Guild(metaclass=YAMLGetter):  class Keys(metaclass=YAMLGetter):      section = "keys" -    deploy_bot: str -    deploy_site: str      site_api: str @@ -410,14 +409,11 @@ class URLs(metaclass=YAMLGetter):      # Misc endpoints      bot_avatar: str -    deploy: str -    gitlab_bot_repo: str -    status: str +    github_bot_repo: str      # Site endpoints      site: str      site_api: str -    site_clean_api: str      site_superstarify_api: str      site_logs_api: str      site_logs_view: str diff --git a/bot/converters.py b/bot/converters.py index af7ecd107..7386187ab 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -71,7 +71,7 @@ class InfractionSearchQuery(Converter):          """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.get_user_info(maybe_snowflake) +            return await ctx.bot.fetch_user(maybe_snowflake)          except (discord.NotFound, discord.HTTPException):              return arg diff --git a/bot/decorators.py b/bot/decorators.py index 3600be3bb..ace9346f0 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -20,7 +20,11 @@ log = logging.getLogger(__name__)  class InChannelCheckFailure(CheckFailure):      """In channel check failure exception.""" -    pass +    def __init__(self, *channels: int): +        self.channels = channels +        channels_str = ', '.join(f"<#{c_id}>" for c_id in channels) + +        super().__init__(f"Sorry, but you may only use this command within {channels_str}.")  def in_channel(*channels: int, bypass_roles: Container[int] = None) -> Callable: @@ -42,10 +46,7 @@ def in_channel(*channels: int, bypass_roles: Container[int] = None) -> Callable:          log.debug(f"{ctx.author} tried to call the '{ctx.command.name}' command. "                    f"The in_channel check failed.") -        channels_str = ', '.join(f"<#{c_id}>" for c_id in channels) -        raise InChannelCheckFailure( -            f"Sorry, but you may only use this command within {channels_str}." -        ) +        raise InChannelCheckFailure(*channels)      return commands.check(predicate) diff --git a/bot/patches/__init__.py b/bot/patches/__init__.py new file mode 100644 index 000000000..60f6becaa --- /dev/null +++ b/bot/patches/__init__.py @@ -0,0 +1,6 @@ +"""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 new file mode 100644 index 000000000..6a73af8c9 --- /dev/null +++ b/bot/patches/message_edited_at.py @@ -0,0 +1,33 @@ +# flake8: noqa +""" +# 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, value) -> 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/utils/__init__.py b/bot/utils/__init__.py index 141559657..618b90748 100644 --- a/bot/utils/__init__.py +++ b/bot/utils/__init__.py @@ -1,5 +1,14 @@ +from abc import ABCMeta  from typing import Any, Generator, Hashable, Iterable +from discord.ext.commands import CogMeta + + +class CogABCMeta(CogMeta, ABCMeta): +    """Metaclass for ABCs meant to be implemented as Cogs.""" + +    pass +  class CaseInsensitiveDict(dict):      """ diff --git a/bot/utils/checks.py b/bot/utils/checks.py index 1f4c1031b..19f64ff9f 100644 --- a/bot/utils/checks.py +++ b/bot/utils/checks.py @@ -29,7 +29,7 @@ def without_role_check(ctx: Context, *role_ids: int) -> bool:                    "This command is restricted by the without_role decorator. Rejecting request.")          return False -    author_roles = (role.id for role in ctx.author.roles) +    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}.") diff --git a/bot/utils/moderation.py b/bot/utils/moderation.py index 9ea2db07c..7860f14a1 100644 --- a/bot/utils/moderation.py +++ b/bot/utils/moderation.py @@ -1,11 +1,11 @@  import logging  from datetime import datetime -from typing import Union +from typing import Optional, Union -from aiohttp import ClientError  from discord import Member, Object, User  from discord.ext.commands import Context +from bot.api import ResponseCodeError  from bot.constants import Keys  log = logging.getLogger(__name__) @@ -21,8 +21,8 @@ async def post_infraction(      expires_at: datetime = None,      hidden: bool = False,      active: bool = True, -) -> Union[dict, None]: -    """Post infraction to the API.""" +) -> Optional[dict]: +    """Posts an infraction to the API."""      payload = {          "actor": ctx.message.author.id,          "hidden": hidden, @@ -36,9 +36,37 @@ async def post_infraction(      try:          response = await ctx.bot.api_client.post('bot/infractions', json=payload) -    except ClientError: -        log.exception("There was an error adding an infraction.") -        await ctx.send(":x: There was an error adding the infraction.") -        return +    except ResponseCodeError as exp: +        if exp.status == 400 and 'user' in exp.response_json: +            log.info( +                f"{ctx.author} tried to add a {type} infraction to `{user.id}`, " +                "but that user id was not found in the database." +            ) +            await ctx.send(f":x: Cannot add infraction, the specified user is not known to the database.") +            return +        else: +            log.exception("An unexpected ResponseCodeError occurred while adding an infraction:") +            await ctx.send(":x: There was an error adding the infraction.") +            return      return response + + +async def already_has_active_infraction(ctx: Context, user: Union[Member, Object, User], type: str) -> bool: +    """Checks if a user already has an active infraction of the given type.""" +    active_infractions = await ctx.bot.api_client.get( +        'bot/infractions', +        params={ +            'active': 'true', +            'type': type, +            'user__id': str(user.id) +        } +    ) +    if active_infractions: +        await ctx.send( +            f":x: According to my records, this user already has a {type} infraction. " +            f"See infraction **#{active_infractions[0]['id']}**." +        ) +        return True +    else: +        return False diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index 9975b04e0..3dec42480 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -1,13 +1,15 @@  import asyncio  import contextlib  import logging -from abc import ABC, abstractmethod +from abc import abstractmethod  from typing import Coroutine, Dict, Union +from bot.utils import CogABCMeta +  log = logging.getLogger(__name__) -class Scheduler(ABC): +class Scheduler(metaclass=CogABCMeta):      """Task scheduler."""      def __init__(self): diff --git a/config-default.yml b/config-default.yml index e8ad1d572..403de21ad 100644 --- a/config-default.yml +++ b/config-default.yml @@ -95,7 +95,7 @@ guild:          bot:                              267659945086812160          checkpoint_test:                  422077681434099723          defcon:                           464469101889454091 -        devlog:            &DEVLOG        409308876241108992 +        devlog:            &DEVLOG        622895325144940554          devtest:           &DEVTEST       414574275865870337          help_0:                           303906576991780866          help_1:                           303906556754395136 @@ -103,6 +103,8 @@ guild:          help_3:                           439702951246692352          help_4:                           451312046647148554          help_5:                           454941769734422538 +        help_6:                           587375753306570782 +        help_7:                           587375768556797982          helpers:                          385474242440986624          message_log:       &MESSAGE_LOG   467752170159079424          mod_alerts:                       473092532147060736 @@ -125,8 +127,7 @@ guild:          announcements:                      463658397560995840          champion:                           430492892331769857          contributor:                        295488872404484098 -        developer:                          352427296948486144 -        devops:            &DEVOPS_ROLE     409416496733880320 +        core_developer:                     587606783669829632          jammer:                             423054537079783434          moderator:         &MOD_ROLE        267629731250176001          muted:             &MUTED_ROLE      277914926603829249 @@ -222,13 +223,10 @@ filter:          - *ADMIN_ROLE          - *MOD_ROLE          - *OWNER_ROLE -        - *DEVOPS_ROLE          - *ROCKSTARS_ROLE  keys: -    deploy_bot:  !ENV "DEPLOY_BOT_KEY" -    deploy_site: !ENV "DEPLOY_SITE"      site_api:    !ENV "BOT_API_KEY" @@ -261,11 +259,7 @@ urls:      paste_service:                      !JOIN [*SCHEMA, *PASTE, "/{key}"]      # Snekbox -    snekbox_eval_api: "http://localhost:8060/eval" - -    # Env vars -    deploy: !ENV "DEPLOY_URL" -    status: !ENV "STATUS_URL" +    snekbox_eval_api: "https://snekbox.pythondiscord.com/eval"      # Discord API URLs      discord_api:        &DISCORD_API "https://discordapp.com/api/v7/" @@ -273,7 +267,7 @@ urls:      # Misc URLs      bot_avatar:      "https://raw.githubusercontent.com/discord-python/branding/master/logos/logo_circle/logo_circle.png" -    gitlab_bot_repo: "https://gitlab.com/python-discord/projects/bot" +    github_bot_repo: "https://github.com/python-discord/bot"  anti_spam:      # Clean messages that violate a rule. diff --git a/scripts/deploy-azure.sh b/scripts/deploy-azure.sh index af69ab46b..ed4b719e2 100644 --- a/scripts/deploy-azure.sh +++ b/scripts/deploy-azure.sh @@ -2,11 +2,11 @@  cd .. -# Build and deploy on django branch, only if not a pull request -if [[ ($BUILD_SOURCEBRANCHNAME == 'django') && ($SYSTEM_PULLREQUEST_PULLREQUESTID == '') ]]; then +# Build and deploy on master branch, only if not a pull request +if [[ ($BUILD_SOURCEBRANCHNAME == 'master') && ($SYSTEM_PULLREQUEST_PULLREQUESTID == '') ]]; then      echo "Building image" -    docker build -t pythondiscord/bot:django . +    docker build -t pythondiscord/bot:latest .      echo "Pushing image" -    docker push pythondiscord/bot:django +    docker push pythondiscord/bot:latest  fi diff --git a/scripts/deploy.sh b/scripts/deploy.sh deleted file mode 100644 index 070d0ec26..000000000 --- a/scripts/deploy.sh +++ /dev/null @@ -1,32 +0,0 @@ -#!/bin/bash - -# Build and deploy on master branch -if [[ $CI_COMMIT_REF_SLUG == 'master' ]]; then -    echo "Connecting to docker hub" -    echo "$DOCKER_PASSWORD" | docker login -u "$DOCKER_USERNAME" --password-stdin - -    changed_lines=$(git diff HEAD~1 HEAD docker/base.Dockerfile | wc -l) - -    if [ $changed_lines != '0' ]; then -      echo "base.Dockerfile was changed" - -      echo "Building bot base" -      docker build -t pythondiscord/bot-base:latest -f docker/base.Dockerfile . - -      echo "Pushing image to Docker Hub" -      docker push pythondiscord/bot-base:latest -    else -      echo "base.Dockerfile was not changed, not building" -    fi - -    echo "Building image" -    docker build -t pythondiscord/bot:latest -f docker/bot.Dockerfile . - -    echo "Pushing image" -    docker push pythondiscord/bot:latest - -    echo "Deploying container" -    curl -H "token: $AUTODEPLOY_TOKEN" $AUTODEPLOY_WEBHOOK -else -    echo "Skipping deploy" -fi diff --git a/tests/cogs/test_antispam.py b/tests/cogs/test_antispam.py new file mode 100644 index 000000000..67900b275 --- /dev/null +++ b/tests/cogs/test_antispam.py @@ -0,0 +1,30 @@ +import pytest + +from bot.cogs import antispam + + +def test_default_antispam_config_is_valid(): +    validation_errors = antispam.validate_config() +    assert not validation_errors + + +    ('config', 'expected'), +    ( +        ( +            {'invalid-rule': {}}, +            {'invalid-rule': "`invalid-rule` is not recognized as an antispam rule."} +        ), +        ( +            {'burst': {'interval': 10}}, +            {'burst': "Key `max` is required but not set for rule `burst`"} +        ), +        ( +            {'burst': {'max': 10}}, +            {'burst': "Key `interval` is required but not set for rule `burst`"} +        ) +    ) +) +def test_invalid_antispam_config_returns_validation_errors(config, expected): +    validation_errors = antispam.validate_config(config) +    assert validation_errors == expected diff --git a/tests/cogs/test_information.py b/tests/cogs/test_information.py new file mode 100644 index 000000000..85b2d092e --- /dev/null +++ b/tests/cogs/test_information.py @@ -0,0 +1,163 @@ +import asyncio +import logging +import textwrap +from datetime import datetime +from unittest.mock import MagicMock, patch + +import pytest +from discord import ( +    CategoryChannel, +    Colour, +    TextChannel, +    VoiceChannel, +) + +from bot.cogs import information +from bot.constants import Emojis +from bot.decorators import InChannelCheckFailure +from tests.helpers import AsyncMock + + +def cog(simple_bot): +    return information.Information(simple_bot) + + +def role(name: str, id_: int): +    r = MagicMock() +    r.name = name +    r.id = id_ +    r.mention = f'&{name}' +    return r + + +def member(status: str): +    m = MagicMock() +    m.status = status +    return m + + +def ctx(moderator_role, simple_ctx): +    simple_ctx.author.roles = [moderator_role] +    simple_ctx.guild.created_at = datetime(2001, 1, 1) +    simple_ctx.send = AsyncMock() +    return simple_ctx + + +def test_roles_info_command(cog, ctx): +    everyone_role = MagicMock() +    everyone_role.name = '@everyone'  # should be excluded in the output +    ctx.author.roles.append(everyone_role) +    ctx.guild.roles = ctx.author.roles + +    cog.roles_info.can_run = AsyncMock() +    cog.roles_info.can_run.return_value = True + +    coroutine = cog.roles_info.callback(cog, ctx) + +    assert asyncio.run(coroutine) is None  # no rval +    ctx.send.assert_called_once() +    _, kwargs = ctx.send.call_args +    embed = kwargs.pop('embed') +    assert embed.title == "Role information" +    assert embed.colour == Colour.blurple() +    assert embed.description == f"`{ctx.guild.roles[0].id}` - {ctx.guild.roles[0].mention}\n" +    assert embed.footer.text == "Total roles: 1" + + +# There is no argument passed in here that we can use to test, +# so the return value would change constantly. +@patch('bot.cogs.information.time_since') +def test_server_info_command(time_since_patch, cog, ctx, moderator_role): +    time_since_patch.return_value = '2 days ago' + +    ctx.guild.created_at = datetime(2001, 1, 1) +    ctx.guild.features = ('lemons', 'apples') +    ctx.guild.region = 'The Moon' +    ctx.guild.roles = [moderator_role] +    ctx.guild.channels = [ +        TextChannel( +            state={}, +            guild=ctx.guild, +            data={'id': 42, 'name': 'lemons-offering', 'position': 22, 'type': 'text'} +        ), +        CategoryChannel( +            state={}, +            guild=ctx.guild, +            data={'id': 5125, 'name': 'the-lemon-collection', 'position': 22, 'type': 'category'} +        ), +        VoiceChannel( +            state={}, +            guild=ctx.guild, +            data={'id': 15290, 'name': 'listen-to-lemon', 'position': 22, 'type': 'voice'} +        ) +    ] +    ctx.guild.members = [ +        member('online'), member('online'), +        member('idle'), +        member('dnd'), member('dnd'), member('dnd'), member('dnd'), +        member('offline'), member('offline'), member('offline') +    ] +    ctx.guild.member_count = 1_234 +    ctx.guild.icon_url = 'a-lemon.png' + +    coroutine = cog.server_info.callback(cog, ctx) +    assert asyncio.run(coroutine) is None  # no rval + +    time_since_patch.assert_called_once_with(ctx.guild.created_at, precision='days') +    _, kwargs = ctx.send.call_args +    embed = kwargs.pop('embed') +    assert embed.colour == Colour.blurple() +    assert embed.description == textwrap.dedent(f""" +        **Server information** +        Created: {time_since_patch.return_value} +        Voice region: {ctx.guild.region} +        Features: {', '.join(ctx.guild.features)} + +        **Counts** +        Members: {ctx.guild.member_count:,} +        Roles: {len(ctx.guild.roles)} +        Text: 1 +        Voice: 1 +        Channel categories: 1 + +        **Members** +        {Emojis.status_online} 2 +        {Emojis.status_idle} 1 +        {Emojis.status_dnd} 4 +        {Emojis.status_offline} 3 +        """) +    assert embed.thumbnail.url == 'a-lemon.png' + + +def test_user_info_on_other_users_from_non_moderator(ctx, cog): +    ctx.author = MagicMock() +    ctx.author.__eq__.return_value = False +    ctx.author.roles = [] +    coroutine = cog.user_info.callback(cog, ctx, user='scragly')  # skip checks, pass args + +    assert asyncio.run(coroutine) is None  # no rval +    ctx.send.assert_called_once_with( +        "You may not use this command on users other than yourself." +    ) + + +def test_user_info_in_wrong_channel_from_non_moderator(ctx, cog): +    ctx.author = MagicMock() +    ctx.author.__eq__.return_value = False +    ctx.author.roles = [] + +    coroutine = cog.user_info.callback(cog, ctx) +    message = 'Sorry, but you may only use this command within <#267659945086812160>.' +    with pytest.raises(InChannelCheckFailure, match=message): +        assert asyncio.run(coroutine) is None  # no rval + + +def test_setup(simple_bot, caplog): +    information.setup(simple_bot) +    simple_bot.add_cog.assert_called_once() +    [record] = caplog.records + +    assert record.message == "Cog loaded: Information" +    assert record.levelno == logging.INFO diff --git a/tests/cogs/test_security.py b/tests/cogs/test_security.py new file mode 100644 index 000000000..1efb460fe --- /dev/null +++ b/tests/cogs/test_security.py @@ -0,0 +1,54 @@ +import logging +from unittest.mock import MagicMock + +import pytest +from discord.ext.commands import NoPrivateMessage + +from bot.cogs import security + + +def cog(): +    bot = MagicMock() +    return security.Security(bot) + + +def context(): +    return MagicMock() + + +def test_check_additions(cog): +    cog.bot.check.assert_any_call(cog.check_on_guild) +    cog.bot.check.assert_any_call(cog.check_not_bot) + + +def test_check_not_bot_for_humans(cog, context): +    context.author.bot = False +    assert cog.check_not_bot(context) + + +def test_check_not_bot_for_robots(cog, context): +    context.author.bot = True +    assert not cog.check_not_bot(context) + + +def test_check_on_guild_outside_of_guild(cog, context): +    context.guild = None + +    with pytest.raises(NoPrivateMessage, match="This command cannot be used in private messages."): +        cog.check_on_guild(context) + + +def test_check_on_guild_on_guild(cog, context): +    context.guild = "lemon's lemonade stand" +    assert cog.check_on_guild(context) + + +def test_security_cog_load(caplog): +    bot = MagicMock() +    security.setup(bot) +    bot.add_cog.assert_called_once() +    [record] = caplog.records +    assert record.message == "Cog loaded: Security" +    assert record.levelno == logging.INFO diff --git a/tests/cogs/test_token_remover.py b/tests/cogs/test_token_remover.py new file mode 100644 index 000000000..9d46b3a05 --- /dev/null +++ b/tests/cogs/test_token_remover.py @@ -0,0 +1,133 @@ +import asyncio +from unittest.mock import MagicMock + +import pytest +from discord import Colour + +from bot.cogs.token_remover import ( +    DELETION_MESSAGE_TEMPLATE, +    TokenRemover, +    setup as setup_cog, +) +from bot.constants import Channels, Colours, Event, Icons +from tests.helpers import AsyncMock + + +def token_remover(): +    bot = MagicMock() +    bot.get_cog.return_value = MagicMock() +    bot.get_cog.return_value.send_log_message = AsyncMock() +    return TokenRemover(bot=bot) + + +def message(): +    message = MagicMock() +    message.author.__str__.return_value = 'lemon' +    message.author.bot = False +    message.author.avatar_url_as.return_value = 'picture-lemon.png' +    message.author.id = 42 +    message.author.mention = '@lemon' +    message.channel.send = AsyncMock() +    message.channel.mention = '#lemonade-stand' +    message.content = '' +    message.delete = AsyncMock() +    message.id = 555 +    return message + + +    ('content', 'expected'), +    ( +        ('MTIz', True),  # 123 +        ('YWJj', False),  # abc +    ) +) +def test_is_valid_user_id(content: str, expected: bool): +    assert TokenRemover.is_valid_user_id(content) is expected + + +    ('content', 'expected'), +    ( +        ('DN9r_A', True),  # stolen from dapi, thanks to the author of the 'token' tag! +        ('MTIz', False),  # 123 +    ) +) +def test_is_valid_timestamp(content: str, expected: bool): +    assert TokenRemover.is_valid_timestamp(content) is expected + + +def test_mod_log_property(token_remover): +    token_remover.bot.get_cog.return_value = 'lemon' +    assert token_remover.mod_log == 'lemon' +    token_remover.bot.get_cog.assert_called_once_with('ModLog') + + +def test_ignores_bot_messages(token_remover, message): +    message.author.bot = True +    coroutine = token_remover.on_message(message) +    assert asyncio.run(coroutine) is None + + [email protected]('content', ('', 'lemon wins')) +def test_ignores_messages_without_tokens(token_remover, message, content): +    message.content = content +    coroutine = token_remover.on_message(message) +    assert asyncio.run(coroutine) is None + + [email protected]('content', ('foo.bar.baz', 'x.y.')) +def test_ignores_invalid_tokens(token_remover, message, content): +    message.content = content +    coroutine = token_remover.on_message(message) +    assert asyncio.run(coroutine) is None + + +    'content, censored_token', +    ( +        ('MTIz.DN9R_A.xyz', 'MTIz.DN9R_A.xxx'), +    ) +) +def test_censors_valid_tokens( +    token_remover, message, content, censored_token, caplog +): +    message.content = content +    coroutine = token_remover.on_message(message) +    assert asyncio.run(coroutine) is None  # still no rval + +    # asyncio logs some stuff about its reactor, discard it +    [_, record] = caplog.records +    assert record.message == ( +        "Censored a seemingly valid token sent by lemon (`42`) in #lemonade-stand, " +        f"token was `{censored_token}`" +    ) + +    message.delete.assert_called_once_with() +    message.channel.send.assert_called_once_with( +        DELETION_MESSAGE_TEMPLATE.format(mention='@lemon') +    ) +    token_remover.bot.get_cog.assert_called_with('ModLog') +    message.author.avatar_url_as.assert_called_once_with(static_format='png') + +    mod_log = token_remover.bot.get_cog.return_value +    mod_log.ignore.assert_called_once_with(Event.message_delete, message.id) +    mod_log.send_log_message.assert_called_once_with( +        icon_url=Icons.token_removed, +        colour=Colour(Colours.soft_red), +        title="Token removed!", +        text=record.message, +        thumbnail='picture-lemon.png', +        channel_id=Channels.mod_alerts +    ) + + +def test_setup(caplog): +    bot = MagicMock() +    setup_cog(bot) +    [record] = caplog.records + +    bot.add_cog.assert_called_once() +    assert record.message == "Cog loaded: TokenRemover" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 000000000..d3de4484d --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,32 @@ +from unittest.mock import MagicMock + +import pytest + +from bot.constants import Roles +from tests.helpers import AsyncMock + + +def moderator_role(): +    mock = MagicMock() +    mock.id = Roles.moderator +    mock.name = 'Moderator' +    mock.mention = f'&{mock.name}' +    return mock + + +def simple_bot(): +    mock = MagicMock() +    mock._before_invoke = AsyncMock() +    mock._after_invoke = AsyncMock() +    mock.can_run = AsyncMock() +    mock.can_run.return_value = True +    return mock + + +def simple_ctx(simple_bot): +    mock = MagicMock() +    mock.bot = simple_bot +    return mock diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 000000000..2908294f7 --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,29 @@ +import asyncio +import functools +from unittest.mock import MagicMock + + +__all__ = ('AsyncMock', 'async_test') + + +# TODO: Remove me on 3.8 +class AsyncMock(MagicMock): +    async def __call__(self, *args, **kwargs): +        return super(AsyncMock, self).__call__(*args, **kwargs) + + +def async_test(wrapped): +    """ +    Run a test case via asyncio. + +    Example: + +        >>> @async_test +        ... async def lemon_wins(): +        ...     assert True +    """ + +    @functools.wraps(wrapped) +    def wrapper(*args, **kwargs): +        return asyncio.run(wrapped(*args, **kwargs)) +    return wrapper diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 000000000..ce69ef187 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,106 @@ +import logging +from unittest.mock import MagicMock, patch + +import pytest + +from bot import api +from tests.helpers import async_test + + +def test_loop_is_not_running_by_default(): +    assert not api.loop_is_running() + + +@async_test +async def test_loop_is_running_in_async_test(): +    assert api.loop_is_running() + + +def error_api_response(): +    response = MagicMock() +    response.status = 999 +    return response + + +def api_log_handler(): +    return api.APILoggingHandler(None) + + +def debug_log_record(): +    return logging.LogRecord( +        name='my.logger', level=logging.DEBUG, +        pathname='my/logger.py', lineno=666, +        msg="Lemon wins", args=(), +        exc_info=None +    ) + + +def test_response_code_error_default_initialization(error_api_response): +    error = api.ResponseCodeError(response=error_api_response) +    assert error.status is error_api_response.status +    assert not error.response_json +    assert not error.response_text +    assert error.response is error_api_response + + +def test_response_code_error_default_representation(error_api_response): +    error = api.ResponseCodeError(response=error_api_response) +    assert str(error) == f"Status: {error_api_response.status} Response: " + + +def test_response_code_error_representation_with_nonempty_response_json(error_api_response): +    error = api.ResponseCodeError( +        response=error_api_response, +        response_json={'hello': 'world'} +    ) +    assert str(error) == f"Status: {error_api_response.status} Response: {{'hello': 'world'}}" + + +def test_response_code_error_representation_with_nonempty_response_text(error_api_response): +    error = api.ResponseCodeError( +        response=error_api_response, +        response_text='Lemon will eat your soul' +    ) +    assert str(error) == f"Status: {error_api_response.status} Response: Lemon will eat your soul" + + +@patch('bot.api.APILoggingHandler.ship_off') +def test_emit_appends_to_queue_with_stopped_event_loop( +    ship_off_patch, api_log_handler, debug_log_record +): +    # This is a coroutine so returns something we should await, +    # but asyncio complains about that. To ease testing, we patch +    # `ship_off` to just return a regular value instead. +    ship_off_patch.return_value = 42 +    api_log_handler.emit(debug_log_record) + +    assert api_log_handler.queue == [42] + + +def test_emit_ignores_less_than_debug(debug_log_record, api_log_handler): +    debug_log_record.levelno = logging.DEBUG - 5 +    api_log_handler.emit(debug_log_record) +    assert not api_log_handler.queue + + +def test_schedule_queued_tasks_for_empty_queue(api_log_handler, caplog): +    api_log_handler.schedule_queued_tasks() +    # Logs when tasks are scheduled +    assert not caplog.records + + +@patch('asyncio.create_task') +def test_schedule_queued_tasks_for_nonempty_queue(create_task_patch, api_log_handler, caplog): +    api_log_handler.queue = [555] +    api_log_handler.schedule_queued_tasks() +    assert not api_log_handler.queue +    create_task_patch.assert_called_once_with(555) + +    [record] = caplog.records +    assert record.message == "Scheduled 1 pending logging tasks." +    assert record.levelno == logging.DEBUG +    assert record.name == 'bot.api' +    assert record.__dict__['via_handler'] diff --git a/tests/test_constants.py b/tests/test_constants.py new file mode 100644 index 000000000..e4a29d994 --- /dev/null +++ b/tests/test_constants.py @@ -0,0 +1,23 @@ +import inspect + +import pytest + +from bot import constants + + +    'section', +    ( +        cls +        for (name, cls) in inspect.getmembers(constants) +        if hasattr(cls, 'section') and isinstance(cls, type) +    ) +) +def test_section_configuration_matches_typespec(section): +    for (name, annotation) in section.__annotations__.items(): +        value = getattr(section, name) + +        if getattr(annotation, '_name', None) in ('Dict', 'List'): +            pytest.skip("Cannot validate containers yet") + +        assert isinstance(value, annotation) diff --git a/tests/test_converters.py b/tests/test_converters.py new file mode 100644 index 000000000..3cf774c80 --- /dev/null +++ b/tests/test_converters.py @@ -0,0 +1,93 @@ +import asyncio +from datetime import datetime +from unittest.mock import MagicMock + +import pytest +from discord.ext.commands import BadArgument + +from bot.converters import ( +    ExpirationDate, +    TagContentConverter, +    TagNameConverter, +    ValidPythonIdentifier, +) + + +    ('value', 'expected'), +    ( +        # sorry aliens +        ('2199-01-01T00:00:00', datetime(2199, 1, 1)), +    ) +) +def test_expiration_date_converter_for_valid(value: str, expected: datetime): +    converter = ExpirationDate() +    assert asyncio.run(converter.convert(None, value)) == expected + + +    ('value', 'expected'), +    ( +        ('hello', 'hello'), +        ('  h ello  ', 'h ello') +    ) +) +def test_tag_content_converter_for_valid(value: str, expected: str): +    assert asyncio.run(TagContentConverter.convert(None, value)) == expected + + +    ('value', 'expected'), +    ( +        ('', "Tag contents should not be empty, or filled with whitespace."), +        ('   ', "Tag contents should not be empty, or filled with whitespace.") +    ) +) +def test_tag_content_converter_for_invalid(value: str, expected: str): +    context = MagicMock() +    context.author = 'bob' + +    with pytest.raises(BadArgument, match=expected): +        asyncio.run(TagContentConverter.convert(context, value)) + + +    ('value', 'expected'), +    ( +        ('tracebacks', 'tracebacks'), +        ('Tracebacks', 'tracebacks'), +        ('  Tracebacks  ', 'tracebacks'), +    ) +) +def test_tag_name_converter_for_valid(value: str, expected: str): +    assert asyncio.run(TagNameConverter.convert(None, value)) == expected + + +    ('value', 'expected'), +    ( +        ('👋', "Don't be ridiculous, you can't use that character!"), +        ('', "Tag names should not be empty, or filled with whitespace."), +        ('  ', "Tag names should not be empty, or filled with whitespace."), +        ('42', "Tag names can't be numbers."), +        # Escape question mark as this is evaluated as regular expression. +        ('x' * 128, r"Are you insane\? That's way too long!"), +    ) +) +def test_tag_name_converter_for_invalid(value: str, expected: str): +    context = MagicMock() +    context.author = 'bob' + +    with pytest.raises(BadArgument, match=expected): +        asyncio.run(TagNameConverter.convert(context, value)) + + [email protected]('value', ('foo', 'lemon')) +def test_valid_python_identifier_for_valid(value: str): +    assert asyncio.run(ValidPythonIdentifier.convert(None, value)) == value + + [email protected]('value', ('nested.stuff', '#####')) +def test_valid_python_identifier_for_invalid(value: str): +    with pytest.raises(BadArgument, match=f'`{value}` is not a valid Python identifier'): +        asyncio.run(ValidPythonIdentifier.convert(None, value)) diff --git a/tests/test_pagination.py b/tests/test_pagination.py new file mode 100644 index 000000000..11d6541ae --- /dev/null +++ b/tests/test_pagination.py @@ -0,0 +1,29 @@ +from unittest import TestCase + +import pytest + +from bot import pagination + + +class LinePaginatorTests(TestCase): +    def setUp(self): +        self.paginator = pagination.LinePaginator(prefix='', suffix='', max_size=30) + +    def test_add_line_raises_on_too_long_lines(self): +        message = f"Line exceeds maximum page size {self.paginator.max_size - 2}" +        with pytest.raises(RuntimeError, match=message): +            self.paginator.add_line('x' * self.paginator.max_size) + +    def test_add_line_works_on_small_lines(self): +        self.paginator.add_line('x' * (self.paginator.max_size - 3)) + + +class ImagePaginatorTests(TestCase): +    def setUp(self): +        self.paginator = pagination.ImagePaginator() + +    def test_add_image_appends_image(self): +        image = 'lemon' +        self.paginator.add_image(image) + +        assert self.paginator.images == [image] diff --git a/tests/test_resources.py b/tests/test_resources.py new file mode 100644 index 000000000..2b17aea64 --- /dev/null +++ b/tests/test_resources.py @@ -0,0 +1,18 @@ +import json +import mimetypes +from pathlib import Path +from urllib.parse import urlparse + + +def test_stars_valid(): +    """Validates that `bot/resources/stars.json` contains valid images.""" + +    path = Path('bot', 'resources', 'stars.json') +    content = path.read_text() +    data = json.loads(content) + +    for url in data.values(): +        assert urlparse(url).scheme == 'https' + +        mimetype, _ = mimetypes.guess_type(url) +        assert mimetype in ('image/jpeg', 'image/png') diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/tests/utils/__init__.py diff --git a/tests/utils/test_checks.py b/tests/utils/test_checks.py new file mode 100644 index 000000000..7121acebd --- /dev/null +++ b/tests/utils/test_checks.py @@ -0,0 +1,66 @@ +from unittest.mock import MagicMock + +import pytest + +from bot.utils import checks + + +def context(): +    return MagicMock() + + +def test_with_role_check_without_guild(context): +    context.guild = None + +    assert not checks.with_role_check(context) + + +def test_with_role_check_with_guild_without_required_role(context): +    context.guild = True +    context.author.roles = [] + +    assert not checks.with_role_check(context) + + +def test_with_role_check_with_guild_with_required_role(context): +    context.guild = True +    role = MagicMock() +    role.id = 42 +    context.author.roles = (role,) + +    assert checks.with_role_check(context, role.id) + + +def test_without_role_check_without_guild(context): +    context.guild = None + +    assert not checks.without_role_check(context) + + +def test_without_role_check_with_unwanted_role(context): +    context.guild = True +    role = MagicMock() +    role.id = 42 +    context.author.roles = (role,) + +    assert not checks.without_role_check(context, role.id) + + +def test_without_role_check_without_unwanted_role(context): +    context.guild = True +    role = MagicMock() +    role.id = 42 +    context.author.roles = (role,) + +    assert checks.without_role_check(context, role.id + 10) + + +def test_in_channel_check_for_correct_channel(context): +    context.channel.id = 42 +    assert checks.in_channel_check(context, context.channel.id) + + +def test_in_channel_check_for_incorrect_channel(context): +    context.channel.id = 42 +    assert not checks.in_channel_check(context, context.channel.id + 10) @@ -1,9 +1,11 @@  [flake8]  max-line-length=120 -application_import_names=bot  docstring-convention=all +import-order-style=pycharm +application_import_names=bot,tests +exclude=.cache,.venv,constants.py  ignore= -    P102,B311,W503,E226,S311,T000 +    B311,W503,E226,S311,T000      # Missing Docstrings      D100,D104,D105,D107,      # Docstring Whitespace @@ -14,5 +16,4 @@ ignore=      D400,D401,D402,D404,D405,D406,D407,D408,D409,D410,D411,D412,D413,D414,D416,D417      # Type Annotations      TYP002,TYP003,TYP101,TYP102,TYP204,TYP206 -exclude=.cache,.venv,tests,constants.py -import-order-style=pycharm +per-file-ignores=tests/*:D,TYP | 
