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 |