diff options
104 files changed, 5537 insertions, 4931 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/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4776bc63b..860357868 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,12 +1,10 @@ repos: -- repo: https://github.com/pre-commit/pre-commit-hooks - rev: v2.2.3 +- repo: local hooks: - - id: flake8 - additional_dependencies: [ - "flake8-bugbear", - "flake8-import-order", - "flake8-tidy-imports", - "flake8-todo", - "flake8-string-format" - ]
\ No newline at end of file + - id: flake8 + name: Flake8 + description: This hook runs flake8 within our project's pipenv environment. + entry: pipenv run lint + language: python + types: [python] + require_serial: true
\ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6648ce1f0..a0a1200ec 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,6 +9,7 @@ Note that contributions may be rejected on the basis of a contributor failing to 1. **No force-pushes** or modifying the Git history in any way. 2. If you have direct access to the repository, **create a branch for your changes** and create a pull request for that branch. If not, create a branch on a fork of the repository and create a pull request from there. * It's common practice for a repository to reject direct pushes to `master`, so make branching a habit! + * If PRing from your own fork, **ensure that "Allow edits from maintainers" is checked**. This gives permission for maintainers to commit changes directly to your fork, speeding up the review process. 3. **Adhere to the prevailing code style**, which we enforce using [flake8](http://flake8.pycqa.org/en/latest/index.html). * Run `flake8` against your code **before** you push it. Your commit will be rejected by the build server if it fails to lint. * [Git Hooks](https://git-scm.com/book/en/v2/Customizing-Git-Git-Hooks) are a powerful tool that can be a daunting to set up. Fortunately, [`pre-commit`](https://github.com/pre-commit/pre-commit) abstracts this process away from you and is provided as a dev dependency for this project. Run `pipenv run precommit` when setting up the project and you'll never have to worry about breaking the build for linting errors. @@ -100,6 +101,8 @@ Github [has introduced a new PR feature](https://github.blog/2019-02-14-introduc This feature should be utilized in place of the traditional method of prepending `[WIP]` to the PR title. +As stated earlier, **ensure that "Allow edits from maintainers" is checked**. This gives permission for maintainers to commit changes directly to your fork, speeding up the review process. + ## Footnotes This document was inspired by the [Glowstone contribution guidelines](https://github.com/GlowstoneMC/Glowstone/blob/dev/docs/CONTRIBUTING.md). diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..aa6333380 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +FROM python:3.7-alpine3.7 + +RUN apk add --no-cache \ + build-base \ + freetype-dev \ + git \ + jpeg-dev \ + libffi-dev \ + libxml2 \ + libxml2-dev \ + libxslt-dev \ + tini \ + zlib \ + zlib-dev + +ENV \ + LIBRARY_PATH=/lib:/usr/lib + +RUN pip install -U pipenv + +WORKDIR /bot +COPY . . + +RUN pipenv install --deploy --system + +ENTRYPOINT ["/sbin/tini", "--"] +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 = "*" @@ -17,21 +17,27 @@ aio-pika = "*" python-dateutil = "*" deepdiff = "*" requests = "*" +dateparser = "*" +more_itertools = "~=7.2" urllib3 = ">=1.24.2,<1.25" [dev-packages] -"flake8" = ">=3.6" -"flake8-bugbear" = "*" -"flake8-import-order" = "*" -"flake8-tidy-imports" = "*" -"flake8-todo" = "*" -"flake8-string-format" = "*" +flake8 = "~=3.7" +flake8-annotations = "~=1.0" +flake8-bugbear = "~=19.8" +flake8-docstrings = "~=1.4" +flake8-import-order = "~=0.18" +flake8-string-format = "~=0.2" +flake8-tidy-imports = "~=2.0" +flake8-todo = "~=0.7" +pre-commit = "~=1.18" safety = "*" dodgy = "*" -pre-commit = "*" +pytest = "*" +pytest-cov = "*" [requires] -python_version = "3.6" +python_version = "3.7" [scripts] start = "python -m bot" diff --git a/Pipfile.lock b/Pipfile.lock index 735d7cd96..9bdcff923 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,11 +1,11 @@ { "_meta": { "hash": { - "sha256": "ab3b63b74dbf35fb960913a91e10282121a2776e935d98f0b4c3d780715f7a6b" + "sha256": "29aaaa90a070d544e5b39fb6033410daa9bb7f658077205e44099f3175f6822b" }, "pipfile-spec": 6, "requires": { - "python_version": "3.6" + "python_version": "3.7" }, "sources": [ { @@ -18,11 +18,11 @@ "default": { "aio-pika": { "hashes": [ - "sha256:300474d8b0e9ccde17b2d1e71c3b4f7ba86559cc0842b9355b9eccb12be4a02a", - "sha256:3bc547600344beba8f36edfd1b1ec1c8b30f803ea7c11eaf249683099d07c98b" + "sha256:29f27a8092169924c9eefb0c5e428d216706618dc9caa75ddb7759638e16cf26", + "sha256:4f77ba9b6e7bc27fc88c49638bc3657ae5d4a2539e17fa0c2b25b370547b1b50" ], "index": "pypi", - "version": "==5.5.2" + "version": "==6.1.2" }, "aiodns": { "hashes": [ @@ -34,38 +34,38 @@ }, "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": [ - "sha256:79b41e51481fb7617279414e4428a644a944beb4dea8ea0febd67a8902976250", - "sha256:f134cc91ac111b0135c97539272579b1d15b69f25c75a935f6ee39e5194df231" + "sha256:0b755b748d87d5ec63b4b7f162102333bf0901caf1f8a2bf29467bbdd54e637d", + "sha256:f8eef1f98bc331a266404d925745fac589dab30412688564d740754d6d643863" ], - "version": "==2.5.2" + "version": "==2.7.5" }, "alabaster": { "hashes": [ @@ -90,25 +90,25 @@ }, "babel": { "hashes": [ - "sha256:6778d85147d5d85345c14a26aada5e478ab04e39b078b0745ee6870c2b5cf669", - "sha256:8cba50f48c529ca3fa18cf81fa9403be176d374ac4d60738b839122dfaaa3d23" + "sha256:af92e6106cb7c55286b25b38ad7695f8b4efb36a90ba483d7f7a6628c46158ab", + "sha256:e86135ae101e31e2c8ec20a4e0c5220f4eed12487d5cf3f78be7e98d3a57fc28" ], - "version": "==2.6.0" + "version": "==2.7.0" }, "beautifulsoup4": { "hashes": [ - "sha256:034740f6cb549b4e932ae1ab975581e6103ac8f942200a0e9759065984391858", - "sha256:945065979fb8529dd2f37dbb58f00b661bdbcbebf954f93b32fdf5263ef35348", - "sha256:ba6d5c59906a85ac23dadfe5c88deaf3e179ef565f4898671253e50a78680718" + "sha256:05668158c7b85b791c5abde53e50265e16f98ad601c402ba44d70f96c4159612", + "sha256:25288c9e176f354bf277c0a10aa96c782a6a18a17122dba2e8cec4a97e03343b", + "sha256:f040590be10520f2ea4c2ae8c3dae441c7cfff5308ec9d58a0ec0c1b8f81d469" ], - "version": "==4.7.1" + "version": "==4.8.0" }, "certifi": { "hashes": [ - "sha256:59b7658e26ca9c7339e00f8f4636cdfe59d34fa37b9b04f6f9e9926b3cece1a5", - "sha256:b26104d6835d1f5e49452a26eb2ff87fe7090b89dfcaee5ea2212697e1e1d7ae" + "sha256:e4f3620cfea4f83eedc95b24abd9cd56f3c4b146dd0177e83a21b4eb49e21e50", + "sha256:fd7c7c74727ddcf00e9acd26bba8da604ffec95bf1c2144e67aff7a8b50e6cef" ], - "version": "==2019.3.9" + "version": "==2019.9.11" }, "cffi": { "hashes": [ @@ -150,29 +150,36 @@ ], "version": "==3.0.4" }, + "dateparser": { + "hashes": [ + "sha256:983d84b5e3861cb0aa240cad07f12899bb10b62328aae188b9007e04ce37d665", + "sha256:e1eac8ef28de69a554d5fcdb60b172d526d61924b1a40afbbb08df459a36006b" + ], + "index": "pypi", + "version": "==0.7.2" + }, "deepdiff": { "hashes": [ - "sha256:55e461f56dcae3dc540746b84434562fb7201e5c27ecf28800e4cfdd17f61e56", - "sha256:856966b80109df002a1ee406ba21cd66e64746167b2ea8f5353d692762326ac9" + "sha256:1123762580af0904621136d117c8397392a244d3ff0fa0a50de57a7939582476", + "sha256:6ab13e0cbb627dadc312deaca9bef38de88a737a9bbdbfbe6e3857748219c127" ], "index": "pypi", - "version": "==4.0.6" + "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": [ - "sha256:02aec4bd92ab067f6ff27a38a38a41173bf01bed8f89157768c1573f53e474a6", - "sha256:51e64ef2ebfb29cae1faa133b3710143496eca21c530f3f71424d77687764274", - "sha256:7a4bd47eaf6596e1295ecb11361139febe29b084a87bf005bf899f9a42edc3c6" + "sha256:6c4f696463b79f1fb8ba0c594b63840ebd41f059e92b31957c46b74a4599b6d0", + "sha256:9e4d7ecfc600058e07ba661411a2b7de2fd0fafa17d1a7f7361cd47b1175c827", + "sha256:a2aeea129088da402665e92e0b25b04b073c04b2dce4ab65caaa38b7ce2e1a99" ], - "version": "==0.14" + "version": "==0.15.2" }, "fuzzywuzzy": { "hashes": [ @@ -205,10 +212,10 @@ }, "jsonpickle": { "hashes": [ - "sha256:0231d6f7ebc4723169310141352d9c9b7bbbd6f3be110cf634575d2bf2af91f0", - "sha256:625098cc8e5854b8c23b587aec33bc8e33e0e597636bfaca76152249c78fe5c1" + "sha256:d0c5a4e6cb4e58f6d5406bdded44365c2bcf9c836c4f52910cc9ba7245a59dc2", + "sha256:d3e922d781b1d0096df2dad89a2e1f47177d7969b596aea806a9d91b4626b29b" ], - "version": "==1.1" + "version": "==1.2" }, "logmatic-python": { "hashes": [ @@ -219,35 +226,31 @@ }, "lxml": { "hashes": [ - "sha256:03984196d00670b2ab14ae0ea83d5cc0cfa4f5a42558afa9ab5fa745995328f5", - "sha256:0815b0c9f897468de6a386dc15917a0becf48cc92425613aa8bbfc7f0f82951f", - "sha256:175f3825f075cf02d15099eb52658457cf0ff103dcf11512b5d2583e1d40f58b", - "sha256:30e14c62d88d1e01a26936ecd1c6e784d4afc9aa002bba4321c5897937112616", - "sha256:3210da6f36cf4b835ff1be853962b22cc354d506f493b67a4303c88bbb40d57b", - "sha256:40f60819fbd5bad6e191ba1329bfafa09ab7f3f174b3d034d413ef5266963294", - "sha256:43b26a865a61549919f8a42e094dfdb62847113cf776d84bd6b60e4e3fc20ea3", - "sha256:4a03dd682f8e35a10234904e0b9508d705ff98cf962c5851ed052e9340df3d90", - "sha256:62f382cddf3d2e52cf266e161aa522d54fd624b8cc567bc18f573d9d50d40e8e", - "sha256:7b98f0325be8450da70aa4a796c4f06852949fe031878b4aa1d6c417a412f314", - "sha256:846a0739e595871041385d86d12af4b6999f921359b38affb99cdd6b54219a8f", - "sha256:a3080470559938a09a5d0ec558c005282e99ac77bf8211fb7b9a5c66390acd8d", - "sha256:ad841b78a476623955da270ab8d207c3c694aa5eba71f4792f65926dc46c6ee8", - "sha256:afdd75d9735e44c639ffd6258ce04a2de3b208f148072c02478162d0944d9da3", - "sha256:b4fbf9b552faff54742bcd0791ab1da5863363fb19047e68f6592be1ac2dab33", - "sha256:b90c4e32d6ec089d3fa3518436bdf5ce4d902a0787dbd9bb09f37afe8b994317", - "sha256:b91cfe4438c741aeff662d413fd2808ac901cc6229c838236840d11de4586d63", - "sha256:bdb0593a42070b0a5f138b79b872289ee73c8e25b3f0bea6564e795b55b6bcdd", - "sha256:c4e4bca2bb68ce22320297dfa1a7bf070a5b20bcbaec4ee023f83d2f6e76496f", - "sha256:cec4ab14af9eae8501be3266ff50c3c2aecc017ba1e86c160209bb4f0423df6a", - "sha256:e83b4b2bf029f5104bc1227dbb7bf5ace6fd8fabaebffcd4f8106fafc69fc45f", - "sha256:e995b3734a46d41ae60b6097f7c51ba9958648c6d1e0935b7e0ee446ee4abe22", - "sha256:f679d93dec7f7210575c85379a31322df4c46496f184ef650d3aba1484b38a2d", - "sha256:fd213bb5166e46974f113c8228daaef1732abc47cb561ce9c4c8eaed4bd3b09b", - "sha256:fdcb57b906dbc1f80666e6290e794ab8fb959a2e17aa5aee1758a85d1da4533f", - "sha256:ff424b01d090ffe1947ec7432b07f536912e0300458f9a7f48ea217dd8362b86" - ], - "index": "pypi", - "version": "==4.3.3" + "sha256:02ca7bf899da57084041bb0f6095333e4d239948ad3169443f454add9f4e9cb4", + "sha256:096b82c5e0ea27ce9138bcbb205313343ee66a6e132f25c5ed67e2c8d960a1bc", + "sha256:0a920ff98cf1aac310470c644bc23b326402d3ef667ddafecb024e1713d485f1", + "sha256:17cae1730a782858a6e2758fd20dd0ef7567916c47757b694a06ffafdec20046", + "sha256:17e3950add54c882e032527795c625929613adbd2ce5162b94667334458b5a36", + "sha256:1f4f214337f6ee5825bf90a65d04d70aab05526c08191ab888cb5149501923c5", + "sha256:2e8f77db25b0a96af679e64ff9bf9dddb27d379c9900c3272f3041c4d1327c9d", + "sha256:4dffd405390a45ecb95ab5ab1c1b847553c18b0ef8ed01e10c1c8b1a76452916", + "sha256:6b899931a5648862c7b88c795eddff7588fb585e81cecce20f8d9da16eff96e0", + "sha256:726c17f3e0d7a7200718c9a890ccfeab391c9133e363a577a44717c85c71db27", + "sha256:760c12276fee05c36f95f8040180abc7fbebb9e5011447a97cdc289b5d6ab6fc", + "sha256:796685d3969815a633827c818863ee199440696b0961e200b011d79b9394bbe7", + "sha256:891fe897b49abb7db470c55664b198b1095e4943b9f82b7dcab317a19116cd38", + "sha256:a471628e20f03dcdfde00770eeaf9c77811f0c331c8805219ca7b87ac17576c5", + "sha256:a63b4fd3e2cabdcc9d918ed280bdde3e8e9641e04f3c59a2a3109644a07b9832", + "sha256:b0b84408d4eabc6de9dd1e1e0bc63e7731e890c0b378a62443e5741cfd0ae90a", + "sha256:be78485e5d5f3684e875dab60f40cddace2f5b2a8f7fede412358ab3214c3a6f", + "sha256:c27eaed872185f047bb7f7da2d21a7d8913457678c9a100a50db6da890bc28b9", + "sha256:c81cb40bff373ab7a7446d6bbca0190bccc5be3448b47b51d729e37799bb5692", + "sha256:d11874b3c33ee441059464711cd365b89fa1a9cf19ae75b0c189b01fbf735b84", + "sha256:e9c028b5897901361d81a4718d1db217b716424a0283afe9d6735fe0caf70f79", + "sha256:fe489d486cd00b739be826e8c1be188ddb74c7a1ca784d93d06fda882a6a1681" + ], + "index": "pypi", + "version": "==4.4.1" }, "markdownify": { "hashes": [ @@ -289,6 +292,14 @@ ], "version": "==1.1.1" }, + "more-itertools": { + "hashes": [ + "sha256:409cd48d4db7052af495b09dec721011634af3753ae1ef92d2b32f73a745f832", + "sha256:92b8c4b06dac4f0611c0729b2f2ede52b2e1bac1ab48f089c7ddc12e26bb60c4" + ], + "index": "pypi", + "version": "==7.2.0" + }, "multidict": { "hashes": [ "sha256:024b8129695a952ebd93373e45b5d341dbb87c17ce49637b34000093f243dd4f", @@ -331,10 +342,10 @@ }, "packaging": { "hashes": [ - "sha256:0c98a5d0be38ed775798ece1b9727178c4469d9c3b4ada66e8e6b7849f8732af", - "sha256:9e1cbf8c12b1f1ce0bb5344b8d7ecf66a6f8a6e91bcb0c84593ed6d3ab5c4ab3" + "sha256:a7ac867b97fdc07ee80a8058fe4435ccd274ecc3b0ed61d852d7d53055528cf9", + "sha256:c491ca87294da7cc01902edbe30a5bc6c4c28172b5138ab4e4aa1b9d7bfaeafe" ], - "version": "==19.0" + "version": "==19.1" }, "pamqp": { "hashes": [ @@ -369,53 +380,17 @@ }, "pygments": { "hashes": [ - "sha256:5ffada19f6203563680669ee7f53b64dabbeb100eb51b61996085e99c03b284a", - "sha256:e8218dd399a61674745138520d0d4cf2621d7e032439341bc3f647bff125818d" - ], - "version": "==2.3.1" - }, - "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" + "sha256:71e430bc85c88a430f000ac1d9b331d2407f681d6f6aec95e8bcfbc3df5b0127", + "sha256:881c4c157e45f30af185c1ffe8d549d48ac9127433f2c380c24b84572ad66297" + ], + "version": "==2.4.2" }, "pyparsing": { "hashes": [ - "sha256:1873c03321fc118f4e9746baf201ff990ceb915f433f23b395f5580d1840cb2a", - "sha256:9b6323ef4ab914af344ba97510e966d64ba91055d6b9afa6b30799340e89cc03" + "sha256:6f98a7b9397e206d78cc01df10131398f1c8b8510a2f4d97d9abd82e1aacdd80", + "sha256:d9338df12903bbf5d65a0e4e87c2161968b10d2e489652bb47001d82a9b028b4" ], - "version": "==2.4.0" + "version": "==2.4.2" }, "python-dateutil": { "hashes": [ @@ -433,35 +408,53 @@ }, "pytz": { "hashes": [ - "sha256:303879e36b721603cc54604edcac9d20401bdbe31e1e4fdee5b9f98d5d31dfda", - "sha256:d747dd3d23d77ef44c6a3526e274af6efeb0a6f1afd5a69ba4d5be4098c8e141" + "sha256:26c0b32e437e54a18161324a2fca3c4b9846b74a8dccddd843113109e1116b32", + "sha256:c894d57500a4cd2d5c71114aaab77dbab5eabd9022308ce5ac9bb93a60a6f0c7" ], - "version": "==2019.1" + "version": "==2019.2" }, "pyyaml": { "hashes": [ - "sha256:1adecc22f88d38052fb787d959f003811ca858b799590a5eaa70e63dca50308c", - "sha256:436bc774ecf7c103814098159fbb84c2715d25980175292c648f2da143909f95", - "sha256:460a5a4248763f6f37ea225d19d5c205677d8d525f6a83357ca622ed541830c2", - "sha256:5a22a9c84653debfbf198d02fe592c176ea548cccce47553f35f466e15cf2fd4", - "sha256:7a5d3f26b89d688db27822343dfa25c599627bc92093e788956372285c6298ad", - "sha256:9372b04a02080752d9e6f990179a4ab840227c6e2ce15b95e1278456664cf2ba", - "sha256:a5dcbebee834eaddf3fa7366316b880ff4062e4bcc9787b78c7fbb4a26ff2dd1", - "sha256:aee5bab92a176e7cd034e57f46e9df9a9862a71f8f37cad167c6fc74c65f5b4e", - "sha256:c51f642898c0bacd335fc119da60baae0824f2cde95b0330b56c0553439f0673", - "sha256:c68ea4d3ba1705da1e0d85da6684ac657912679a649e8868bd850d2c299cce13", - "sha256:e23d0cc5299223dcc37885dae624f382297717e459ea24053709675a976a3e19" + "sha256:0113bc0ec2ad727182326b61326afa3d1d8280ae1122493553fd6f4397f33df9", + "sha256:01adf0b6c6f61bd11af6e10ca52b7d4057dd0be0343eb9283c878cf3af56aee4", + "sha256:5124373960b0b3f4aa7df1707e63e9f109b5263eca5976c66e08b1c552d4eaf8", + "sha256:5ca4f10adbddae56d824b2c09668e91219bb178a1eee1faa56af6f99f11bf696", + "sha256:7907be34ffa3c5a32b60b95f4d95ea25361c951383a894fec31be7252b2b6f34", + "sha256:7ec9b2a4ed5cad025c2278a1e6a19c011c80a3caaac804fd2d329e9cc2c287c9", + "sha256:87ae4c829bb25b9fe99cf71fbb2140c448f534e24c998cc60f39ae4f94396a73", + "sha256:9de9919becc9cc2ff03637872a440195ac4241c80536632fffeb6a1e25a74299", + "sha256:a5a85b10e450c66b49f98846937e8cfca1db3127a9d5d1e31ca45c3d0bef4c5b", + "sha256:b0997827b4f6a7c286c01c5f60384d218dca4ed7d9efa945c3e1aa623d5709ae", + "sha256:b631ef96d3222e62861443cc89d6563ba3eeb816eeb96b2629345ab795e53681", + "sha256:bf47c0607522fdbca6c9e817a6e81b08491de50f3766a7a0e6a5be7905961b41", + "sha256:f81025eddd0327c7d4cfe9b62cf33190e1e736cc6e97502b3ec425f574b3e7a8" ], "index": "pypi", - "version": "==5.1" + "version": "==5.1.2" + }, + "regex": { + "hashes": [ + "sha256:1e9f9bc44ca195baf0040b1938e6801d2f3409661c15fe57f8164c678cfc663f", + "sha256:587b62d48ca359d2d4f02d486f1f0aa9a20fbaf23a9d4198c4bed72ab2f6c849", + "sha256:835ccdcdc612821edf132c20aef3eaaecfb884c9454fdc480d5887562594ac61", + "sha256:93f6c9da57e704e128d90736430c5c59dd733327882b371b0cae8833106c2a21", + "sha256:a46f27d267665016acb3ec8c6046ec5eae8cf80befe85ba47f43c6f5ec636dcd", + "sha256:c5c8999b3a341b21ac2c6ec704cfcccbc50f1fedd61b6a8ee915ca7fd4b0a557", + "sha256:d4d1829cf97632673aa49f378b0a2c3925acd795148c5ace8ef854217abbee89", + "sha256:d96479257e8e4d1d7800adb26bf9c5ca5bab1648a1eddcac84d107b73dc68327", + "sha256:f20f4912daf443220436759858f96fefbfc6c6ba9e67835fd6e4e9b73582791a", + "sha256:f2b37b5b2c2a9d56d9e88efef200ec09c36c7f323f9d58d0b985a90923df386d", + "sha256:fe765b809a1f7ce642c2edeee351e7ebd84391640031ba4b60af8d91a9045890" + ], + "version": "==2019.8.19" }, "requests": { "hashes": [ - "sha256:502a824f31acdacb3a35b6690b5fbf0bc41d63a24a45c4004352b0242707598e", - "sha256:7bf2a778576d825600030a110f3c0e3e8edc51dfaafe1c146e39a2027784957b" + "sha256:11e007a8a2aa0323f5a921e9e6a2d7e4e67d9877e85773fba9ba6419025cbeb4", + "sha256:9cf5292fcd0f598c671cfc1e0d7d1a7f13bb8085e9a590f48c010551dc6c4b31" ], "index": "pypi", - "version": "==2.21.0" + "version": "==2.22.0" }, "six": { "hashes": [ @@ -472,25 +465,24 @@ }, "snowballstemmer": { "hashes": [ - "sha256:919f26a68b2c17a7634da993d91339e288964f93c274f1343e3bbbe2096e1128", - "sha256:9f3bcd3c401c3e862ec0ebe6d2c069ebc012ce142cce209c098ccb5b09136e89" + "sha256:713e53b79cbcf97bc5245a06080a33d54a77e7cce2f789c835a143bcdb5c033e" ], - "version": "==1.2.1" + "version": "==1.9.1" }, "soupsieve": { "hashes": [ - "sha256:6898e82ecb03772a0d82bd0d0a10c0d6dcc342f77e0701d0ec4a8271be465ece", - "sha256:b20eff5e564529711544066d7dc0f7661df41232ae263619dede5059799cdfca" + "sha256:8662843366b8d8779dec4e2f921bebec9afd856a5ff2e82cd419acc5054a1a92", + "sha256:a5a6166b4767725fd52ae55fee8c8b6137d9a51e9f1edea461a062a759160118" ], - "version": "==1.9.1" + "version": "==1.9.3" }, "sphinx": { "hashes": [ - "sha256:423280646fb37944dd3c85c58fb92a20d745793a9f6c511f59da82fa97cd404b", - "sha256:de930f42600a4fef993587633984cc5027dedba2464bcf00ddace26b40f8d9ce" + "sha256:0d586b0f8c2fc3cc6559c5e8fd6124628110514fda0e5d7c82e682d749d2e845", + "sha256:839a3ed6f6b092bb60f492024489cc9e6991360fb9f52ed6361acd510d261069" ], "index": "pypi", - "version": "==2.0.1" + "version": "==2.2.0" }, "sphinxcontrib-applehelp": { "hashes": [ @@ -534,13 +526,20 @@ ], "version": "==1.1.3" }, + "tzlocal": { + "hashes": [ + "sha256:11c9f16e0a633b4b60e1eede97d8a46340d042e67b670b290ca526576e039048", + "sha256:949b9dd5ba4be17190a80c0268167d7e6c92c62b30026cf9764caf3e308e5590" + ], + "version": "==2.0.0" + }, "urllib3": { "hashes": [ - "sha256:4c291ca23bbb55c76518905869ef34bdd5f0e46af7afe6861e8375643ffee1a0", - "sha256:9a247273df709c4fedb38c711e44292304f73f39ab01beda9f6b9fc375669ac3" + "sha256:2393a695cd12afedd0dcb26fe5d50d0cf248e5a66f75dbd89a3d4eb333a61af4", + "sha256:a637e5fae88995b256e3409dc4d52c2e2e0ba32c42a6365fee8bbd2238de3cfb" ], "index": "pypi", - "version": "==1.24.2" + "version": "==1.24.3" }, "websockets": { "hashes": [ @@ -588,10 +587,17 @@ "develop": { "aspy.yaml": { "hashes": [ - "sha256:ae249074803e8b957c83fdd82a99160d0d6d26dff9ba81ba608b42eebd7d8cd3", - "sha256:c7390d79f58eb9157406966201abf26da0d56c07e0ff0deadc39c8f4dbc13482" + "sha256:463372c043f70160a9ec950c3f1e4c3a82db5fca01d334b6bc89c7164d744bdc", + "sha256:e7c742382eff2caed61f87a39d13f99109088e5e93f04d76eb8d4b28aa143f45" ], - "version": "==1.2.0" + "version": "==1.3.0" + }, + "atomicwrites": { + "hashes": [ + "sha256:03472c30eb2c5d1ba9227e4c2ca66ab8287fbfbbda3888aa93dc2e28fc6811b4", + "sha256:75a9445bac02d8d058d5e1fe689654ba5a6556a1dfd8ce6ec55a0ed79866cfa6" + ], + "version": "==1.3.0" }, "attrs": { "hashes": [ @@ -602,17 +608,17 @@ }, "certifi": { "hashes": [ - "sha256:59b7658e26ca9c7339e00f8f4636cdfe59d34fa37b9b04f6f9e9926b3cece1a5", - "sha256:b26104d6835d1f5e49452a26eb2ff87fe7090b89dfcaee5ea2212697e1e1d7ae" + "sha256:e4f3620cfea4f83eedc95b24abd9cd56f3c4b146dd0177e83a21b4eb49e21e50", + "sha256:fd7c7c74727ddcf00e9acd26bba8da604ffec95bf1c2144e67aff7a8b50e6cef" ], - "version": "==2019.3.9" + "version": "==2019.9.11" }, "cfgv": { "hashes": [ - "sha256:6e9f2feea5e84bc71e56abd703140d7a2c250fc5ba38b8702fd6a68ed4e3b2ef", - "sha256:e7f186d4a36c099a9e20b04ac3108bd8bb9b9257e692ce18c8c3764d5cb12172" + "sha256:edb387943b665bf9c434f717bf630fa78aecd53d5900d2e05da6ad6048553144", + "sha256:fbd93c9ab0a523bf7daec408f3be2ed99a980e20b2d19b50fc184ca6b820d289" ], - "version": "==1.6.0" + "version": "==2.0.1" }, "chardet": { "hashes": [ @@ -628,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" @@ -651,19 +694,35 @@ }, "flake8": { "hashes": [ - "sha256:859996073f341f2670741b51ec1e67a01da142831aa1fdc6242dbf88dffbe661", - "sha256:a796a115208f5c03b18f332f7c11729812c8c3ded6c46319c59b53efd3819da8" + "sha256:19241c1cbc971b9962473e4438a2ca19749a7dd002dd1a946eaba171b4114548", + "sha256:8e9dfa3cecb2400b3738a42c54c3043e821682b9c840b0448c0503f781130696" + ], + "index": "pypi", + "version": "==3.7.8" + }, + "flake8-annotations": { + "hashes": [ + "sha256:1309f2bc9853a2d77d578b089d331b0b832b40c97932641e136e1b49d3650c82", + "sha256:3ecdd27054c3eed6484139025698465e3c9f4e68dbd5043d0204fcb2550ee27b" ], "index": "pypi", - "version": "==3.7.7" + "version": "==1.0.0" }, "flake8-bugbear": { "hashes": [ - "sha256:5070774b668be92c4312e5ca82748ddf4ecaa7a24ff062662681bb745c7896eb", - "sha256:fef9c9826d14ec23187ae1edeb3c6513c4e46bf0e70d86bac38f7d9aabae113d" + "sha256:d8c466ea79d5020cb20bf9f11cf349026e09517a42264f313d3f6fddb83e0571", + "sha256:ded4d282778969b5ab5530ceba7aa1a9f1b86fa7618fc96a19a1d512331640f8" + ], + "index": "pypi", + "version": "==19.8.0" + }, + "flake8-docstrings": { + "hashes": [ + "sha256:1666dd069c9c457ee57e80af3c1a6b37b00cc1801c6fde88e455131bb2e186cd", + "sha256:9c0db5a79a1affd70fdf53b8765c8a26bf968e59e0252d7f2fc546b41c0cda06" ], "index": "pypi", - "version": "==19.3.0" + "version": "==1.4.0" }, "flake8-import-order": { "hashes": [ @@ -698,10 +757,10 @@ }, "identify": { "hashes": [ - "sha256:443f419ca6160773cbaf22dbb302b1e436a386f23129dbb5482b68a147c2eca9", - "sha256:bd7f15fe07112b713fb68fbdde3a34dd774d9062128f2c398104889f783f989d" + "sha256:4f1fe9a59df4e80fcb0213086fcf502bc1765a01ea4fe8be48da3b65afd2a017", + "sha256:d8919589bd2a5f99c66302fec0ef9027b12ae150b0b0213999ad3f695fc7296e" ], - "version": "==1.4.2" + "version": "==1.4.7" }, "idna": { "hashes": [ @@ -712,10 +771,11 @@ }, "importlib-metadata": { "hashes": [ - "sha256:46fc60c34b6ed7547e2a723fc8de6dc2e3a1173f8423246b3ce497f064e9c3de", - "sha256:bc136180e961875af88b1ab85b4009f4f1278f8396a60526c0009f503a1a96ca" + "sha256:aa18d7378b00b40847790e7c27e11673d7fed219354109d0e7b9e5b25dc3ad26", + "sha256:d5f18a79777f3aa179c145737780282e27b508fc8fd688cb17c7a813e8bd39af" ], - "version": "==0.9" + "markers": "python_version < '3.8'", + "version": "==0.23" }, "mccabe": { "hashes": [ @@ -724,6 +784,14 @@ ], "version": "==0.6.1" }, + "more-itertools": { + "hashes": [ + "sha256:409cd48d4db7052af495b09dec721011634af3753ae1ef92d2b32f73a745f832", + "sha256:92b8c4b06dac4f0611c0729b2f2ede52b2e1bac1ab48f089c7ddc12e26bb60c4" + ], + "index": "pypi", + "version": "==7.2.0" + }, "nodeenv": { "hashes": [ "sha256:ad8259494cf1c9034539f6cced78a1da4840a4b157e23640bc4a0c0546b0cb7a" @@ -732,18 +800,32 @@ }, "packaging": { "hashes": [ - "sha256:0c98a5d0be38ed775798ece1b9727178c4469d9c3b4ada66e8e6b7849f8732af", - "sha256:9e1cbf8c12b1f1ce0bb5344b8d7ecf66a6f8a6e91bcb0c84593ed6d3ab5c4ab3" + "sha256:a7ac867b97fdc07ee80a8058fe4435ccd274ecc3b0ed61d852d7d53055528cf9", + "sha256:c491ca87294da7cc01902edbe30a5bc6c4c28172b5138ab4e4aa1b9d7bfaeafe" ], - "version": "==19.0" + "version": "==19.1" + }, + "pluggy": { + "hashes": [ + "sha256:0db4b7601aae1d35b4a033282da476845aa19185c1e6964b25cf324b5e4ec3e6", + "sha256:fa5fa1622fa6dd5c030e9cad086fa19ef6a0cf6d7a2d12318e10cb49d6d68f34" + ], + "version": "==0.13.0" }, "pre-commit": { "hashes": [ - "sha256:2576a2776098f3902ef9540a84696e8e06bf18a337ce43a6a889e7fa5d26c4c5", - "sha256:82f2f2d657d7f9280de9f927ae56886d60b9ef7f3714eae92d12713cd9cb9e11" + "sha256:1d3c0587bda7c4e537a46c27f2c84aa006acc18facf9970bf947df596ce91f3f", + "sha256:fa78ff96e8e9ac94c748388597693f18b041a181c94a4f039ad20f45287ba44a" ], "index": "pypi", - "version": "==1.15.2" + "version": "==1.18.3" + }, + "py": { + "hashes": [ + "sha256:64f65755aee5b381cea27766a3a147c3f15b9b6b9ac88676de66ba2ae36793fa", + "sha256:dc639b046a6e2cff5bbe40194ad65936d6ba360b52b3c3fe1d08a82dd50b5e53" + ], + "version": "==1.8.0" }, "pycodestyle": { "hashes": [ @@ -752,6 +834,13 @@ ], "version": "==2.5.0" }, + "pydocstyle": { + "hashes": [ + "sha256:04c84e034ebb56eb6396c820442b8c4499ac5eb94a3bda88951ac3dc519b6058", + "sha256:66aff87ffe34b1e49bff2dd03a88ce6843be2f3346b0c9814410d34987fbab59" + ], + "version": "==4.0.1" + }, "pyflakes": { "hashes": [ "sha256:17dbeb2e3f4d772725c777fabc446d5634d1038f234e77343108ce445ea69ce0", @@ -761,35 +850,53 @@ }, "pyparsing": { "hashes": [ - "sha256:1873c03321fc118f4e9746baf201ff990ceb915f433f23b395f5580d1840cb2a", - "sha256:9b6323ef4ab914af344ba97510e966d64ba91055d6b9afa6b30799340e89cc03" + "sha256:6f98a7b9397e206d78cc01df10131398f1c8b8510a2f4d97d9abd82e1aacdd80", + "sha256:d9338df12903bbf5d65a0e4e87c2161968b10d2e489652bb47001d82a9b028b4" ], - "version": "==2.4.0" + "version": "==2.4.2" + }, + "pytest": { + "hashes": [ + "sha256:95d13143cc14174ca1a01ec68e84d76ba5d9d493ac02716fd9706c949a505210", + "sha256:b78fe2881323bd44fd9bd76e5317173d4316577e7b1cddebae9136a4495ec865" + ], + "index": "pypi", + "version": "==5.1.2" + }, + "pytest-cov": { + "hashes": [ + "sha256:2b097cde81a302e1047331b48cadacf23577e431b61e9c6f49a1170bbe3d3da6", + "sha256:e00ea4fdde970725482f1f35630d12f074e121a23801aabf2ae154ec6bdd343a" + ], + "index": "pypi", + "version": "==2.7.1" }, "pyyaml": { "hashes": [ - "sha256:1adecc22f88d38052fb787d959f003811ca858b799590a5eaa70e63dca50308c", - "sha256:436bc774ecf7c103814098159fbb84c2715d25980175292c648f2da143909f95", - "sha256:460a5a4248763f6f37ea225d19d5c205677d8d525f6a83357ca622ed541830c2", - "sha256:5a22a9c84653debfbf198d02fe592c176ea548cccce47553f35f466e15cf2fd4", - "sha256:7a5d3f26b89d688db27822343dfa25c599627bc92093e788956372285c6298ad", - "sha256:9372b04a02080752d9e6f990179a4ab840227c6e2ce15b95e1278456664cf2ba", - "sha256:a5dcbebee834eaddf3fa7366316b880ff4062e4bcc9787b78c7fbb4a26ff2dd1", - "sha256:aee5bab92a176e7cd034e57f46e9df9a9862a71f8f37cad167c6fc74c65f5b4e", - "sha256:c51f642898c0bacd335fc119da60baae0824f2cde95b0330b56c0553439f0673", - "sha256:c68ea4d3ba1705da1e0d85da6684ac657912679a649e8868bd850d2c299cce13", - "sha256:e23d0cc5299223dcc37885dae624f382297717e459ea24053709675a976a3e19" + "sha256:0113bc0ec2ad727182326b61326afa3d1d8280ae1122493553fd6f4397f33df9", + "sha256:01adf0b6c6f61bd11af6e10ca52b7d4057dd0be0343eb9283c878cf3af56aee4", + "sha256:5124373960b0b3f4aa7df1707e63e9f109b5263eca5976c66e08b1c552d4eaf8", + "sha256:5ca4f10adbddae56d824b2c09668e91219bb178a1eee1faa56af6f99f11bf696", + "sha256:7907be34ffa3c5a32b60b95f4d95ea25361c951383a894fec31be7252b2b6f34", + "sha256:7ec9b2a4ed5cad025c2278a1e6a19c011c80a3caaac804fd2d329e9cc2c287c9", + "sha256:87ae4c829bb25b9fe99cf71fbb2140c448f534e24c998cc60f39ae4f94396a73", + "sha256:9de9919becc9cc2ff03637872a440195ac4241c80536632fffeb6a1e25a74299", + "sha256:a5a85b10e450c66b49f98846937e8cfca1db3127a9d5d1e31ca45c3d0bef4c5b", + "sha256:b0997827b4f6a7c286c01c5f60384d218dca4ed7d9efa945c3e1aa623d5709ae", + "sha256:b631ef96d3222e62861443cc89d6563ba3eeb816eeb96b2629345ab795e53681", + "sha256:bf47c0607522fdbca6c9e817a6e81b08491de50f3766a7a0e6a5be7905961b41", + "sha256:f81025eddd0327c7d4cfe9b62cf33190e1e736cc6e97502b3ec425f574b3e7a8" ], "index": "pypi", - "version": "==5.1" + "version": "==5.1.2" }, "requests": { "hashes": [ - "sha256:502a824f31acdacb3a35b6690b5fbf0bc41d63a24a45c4004352b0242707598e", - "sha256:7bf2a778576d825600030a110f3c0e3e8edc51dfaafe1c146e39a2027784957b" + "sha256:11e007a8a2aa0323f5a921e9e6a2d7e4e67d9877e85773fba9ba6419025cbeb4", + "sha256:9cf5292fcd0f598c671cfc1e0d7d1a7f13bb8085e9a590f48c010551dc6c4b31" ], "index": "pypi", - "version": "==2.21.0" + "version": "==2.22.0" }, "safety": { "hashes": [ @@ -806,6 +913,12 @@ ], "version": "==1.12.0" }, + "snowballstemmer": { + "hashes": [ + "sha256:713e53b79cbcf97bc5245a06080a33d54a77e7cce2f789c835a143bcdb5c033e" + ], + "version": "==1.9.1" + }, "toml": { "hashes": [ "sha256:229f81c57791a41d65e399fc06bf0848bab550a9dfd5ed66df18ce5f05e73d5c", @@ -815,25 +928,32 @@ }, "urllib3": { "hashes": [ - "sha256:4c291ca23bbb55c76518905869ef34bdd5f0e46af7afe6861e8375643ffee1a0", - "sha256:9a247273df709c4fedb38c711e44292304f73f39ab01beda9f6b9fc375669ac3" + "sha256:2393a695cd12afedd0dcb26fe5d50d0cf248e5a66f75dbd89a3d4eb333a61af4", + "sha256:a637e5fae88995b256e3409dc4d52c2e2e0ba32c42a6365fee8bbd2238de3cfb" ], "index": "pypi", - "version": "==1.24.2" + "version": "==1.24.3" }, "virtualenv": { "hashes": [ - "sha256:15ee248d13e4001a691d9583948ad3947bcb8a289775102e4c4aa98a8b7a6d73", - "sha256:bfc98bb9b42a3029ee41b96dc00a34c2f254cbf7716bec824477b2c82741a5c4" + "sha256:680af46846662bb38c5504b78bad9ed9e4f3ba2d54f54ba42494fdf94337fe30", + "sha256:f78d81b62d3147396ac33fc9d77579ddc42cc2a98dd9ea38886f616b33bc7fb2" ], - "version": "==16.5.0" + "version": "==16.7.5" + }, + "wcwidth": { + "hashes": [ + "sha256:3df37372226d6e63e1b1e1eda15c594bca98a22d33a23832a90998faa96bc65e", + "sha256:f4ebe71925af7b40a864553f761ed559b43544f8f71746c2d756c7fe788ade7c" + ], + "version": "==0.1.7" }, "zipp": { "hashes": [ - "sha256:139391b239594fd8b91d856bc530fbd2df0892b17dd8d98a91f018715954185f", - "sha256:8047e4575ce8d700370a3301bbfc972896a5845eb62dd535da395b86be95dfad" + "sha256:3718b1cbcd963c7d4c5511a8240812904164b7f381b647143a89d3b98f9bcd8e", + "sha256:f06903e9f1f43b12d371004b4ac7b06ab39a44adc747266928ae6debfa7b3335" ], - "version": "==0.4.0" + "version": "==0.6.0" } } } diff --git a/azure-pipelines.yml b/azure-pipelines.yml index a14364881..4dcad685c 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -10,7 +10,7 @@ jobs: displayName: 'Lint & Test' pool: - vmImage: 'Ubuntu 16.04' + vmImage: ubuntu-16.04 variables: PIPENV_CACHE_DIR: ".cache/pipenv" @@ -18,10 +18,9 @@ jobs: PIP_SRC: ".cache/src" steps: - - script: sudo apt-get update - displayName: 'Updating package list' - - - script: sudo apt-get install build-essential curl docker libffi-dev libfreetype6-dev libxml2 libxml2-dev libxslt1-dev zlib1g zlib1g-dev + - script: | + sudo apt-get update + sudo apt-get install build-essential curl docker libffi-dev libfreetype6-dev libxml2 libxml2-dev libxslt1-dev zlib1g zlib1g-dev displayName: 'Install base dependencies' - task: UsePythonVersion@0 @@ -39,6 +38,23 @@ jobs: - script: python -m flake8 displayName: 'Run linter' + - 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' @@ -54,7 +70,5 @@ jobs: - task: ShellScript@2 displayName: 'Build and deploy containers' - inputs: scriptPath: scripts/deploy-azure.sh - args: '$(AUTODEPLOY_TOKEN) $(AUTODEPLOY_WEBHOOK)' diff --git a/bot/__init__.py b/bot/__init__.py index a088138a0..d094e8c13 100644 --- a/bot/__init__.py +++ b/bot/__init__.py @@ -9,7 +9,7 @@ logging.TRACE = 5 logging.addLevelName(logging.TRACE, "TRACE") -def monkeypatch_trace(self, msg, *args, **kwargs): +def monkeypatch_trace(self: logging.Logger, msg: str, *args, **kwargs) -> None: """ Log 'msg % args' with severity 'TRACE'. @@ -55,7 +55,7 @@ else: logging.basicConfig( - format="%(asctime)s pd.beardfist.com Bot: | %(name)30s | %(levelname)8s | %(message)s", + format="%(asctime)s pd.beardfist.com Bot: | %(name)33s | %(levelname)8s | %(message)s", datefmt="%b %d %H:%M:%S", level=logging.TRACE if DEBUG_MODE else logging.INFO, handlers=logging_handlers diff --git a/bot/__main__.py b/bot/__main__.py index ead6d287a..f25693734 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -1,21 +1,23 @@ +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 -from bot.utils.service_discovery import wait_for_rmq -log = logging.getLogger(__name__) +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 @@ -27,18 +29,11 @@ bot.http_session = ClientSession( family=socket.AF_INET, ) ) - -log.info("Waiting for RabbitMQ...") - -has_rmq = wait_for_rmq() - -if has_rmq: - log.info("RabbitMQ found") -else: - log.warning("Timed out while waiting for RabbitMQ") +bot.api_client = APIClient(loop=asyncio.get_event_loop()) +log.addHandler(APILoggingHandler(bot.api_client)) # Internal/debug -bot.load_extension("bot.cogs.events") +bot.load_extension("bot.cogs.error_handler") bot.load_extension("bot.cogs.filtering") bot.load_extension("bot.cogs.logging") bot.load_extension("bot.cogs.modlog") @@ -46,12 +41,10 @@ bot.load_extension("bot.cogs.security") # Commands, etc bot.load_extension("bot.cogs.antispam") -bot.load_extension("bot.cogs.bigbrother") bot.load_extension("bot.cogs.bot") bot.load_extension("bot.cogs.clean") bot.load_extension("bot.cogs.cogs") bot.load_extension("bot.cogs.help") -bot.load_extension("bot.cogs.rules") # Only load this in production if not DEBUG_MODE: @@ -61,10 +54,8 @@ 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.fun") bot.load_extension("bot.cogs.information") bot.load_extension("bot.cogs.jams") bot.load_extension("bot.cogs.moderation") @@ -74,14 +65,18 @@ bot.load_extension("bot.cogs.reminders") bot.load_extension("bot.cogs.site") bot.load_extension("bot.cogs.snekbox") bot.load_extension("bot.cogs.superstarify") +bot.load_extension("bot.cogs.sync") bot.load_extension("bot.cogs.tags") bot.load_extension("bot.cogs.token_remover") bot.load_extension("bot.cogs.utils") +bot.load_extension("bot.cogs.watchchannels") bot.load_extension("bot.cogs.wolfram") -if has_rmq: - bot.load_extension("bot.cogs.rmq") +# 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 new file mode 100644 index 000000000..7f26e5305 --- /dev/null +++ b/bot/api.py @@ -0,0 +1,180 @@ +import asyncio +import logging +from typing import Optional +from urllib.parse import quote as quote_url + +import aiohttp + +from .constants import Keys, URLs + +log = logging.getLogger(__name__) + + +class ResponseCodeError(ValueError): + """Raised when a non-OK HTTP response is received.""" + + 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.""" + + def __init__(self, **kwargs): + auth_headers = { + 'Authorization': f"Token {Keys.site_api}" + } + + if 'headers' in kwargs: + kwargs['headers'].update(auth_headers) + else: + kwargs['headers'] = auth_headers + + self.session = aiohttp.ClientSession(**kwargs) + + @staticmethod + def _url_for(endpoint: str) -> str: + return f"{URLs.site_schema}{URLs.site_api}/{quote_url(endpoint)}" + + 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: + 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: + 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: + 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: + 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: + 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) -> Optional[dict]: + """Site API DELETE.""" + async with self.session.delete(self._url_for(endpoint), *args, **kwargs) as resp: + if resp.status == 204: + return None + + await self.maybe_raise_for_status(resp, raise_for_status) + return await resp.json() + + +def loop_is_running() -> bool: + """ + Determine if there is a running asyncio event loop. + + This helps enable "call this when event loop is running" logic (see: Twisted's `callWhenRunning`), + which is currently not provided by asyncio. + """ + try: + asyncio.get_running_loop() + except RuntimeError: + return False + return True + + +class APILoggingHandler(logging.StreamHandler): + """Site API logging handler.""" + + def __init__(self, client: APIClient): + logging.StreamHandler.__init__(self) + self.client = client + + # internal batch of shipoff tasks that must not be scheduled + # on the event loop yet - scheduled when the event loop is ready. + self.queue = [] + + async def ship_off(self, payload: dict) -> None: + """Ship log payload to the logging API.""" + try: + await self.client.post('logs', json=payload) + except ResponseCodeError as err: + log.warning( + "Cannot send logging record to the site, got code %d.", + err.response.status, + extra={'via_handler': True} + ) + except Exception as err: + log.warning( + "Cannot send logging record to the site: %r", + err, + extra={'via_handler': True} + ) + + def emit(self, record: logging.LogRecord) -> None: + """ + Determine if a log record should be shipped to the logging API. + + If the asyncio event loop is not yet running, log records will instead be put in a queue + which will be consumed once the event loop is running. + + The following two conditions are set: + 1. Do not log anything below DEBUG (only applies to the monkeypatched `TRACE` level) + 2. Ignore log records originating from this logging handler itself to prevent infinite recursion + """ + if ( + record.levelno >= logging.DEBUG + and not record.__dict__.get('via_handler') + ): + payload = { + 'application': 'bot', + 'logger_name': record.name, + 'level': record.levelname.lower(), + 'module': record.module, + 'line': record.lineno, + 'message': self.format(record) + } + + task = self.ship_off(payload) + if not loop_is_running(): + self.queue.append(task) + else: + asyncio.create_task(task) + self.schedule_queued_tasks() + + def schedule_queued_tasks(self) -> None: + """Consume the queue and schedule the logging of each queued record.""" + for task in self.queue: + asyncio.create_task(task) + + if self.queue: + log.debug( + "Scheduled %d pending logging tasks.", + len(self.queue), + extra={'via_handler': True} + ) + + self.queue.clear() diff --git a/bot/cogs/alias.py b/bot/cogs/alias.py index bf40fe409..80ff37983 100644 --- a/bot/cogs/alias.py +++ b/bot/cogs/alias.py @@ -1,37 +1,25 @@ import inspect import logging +from typing import Union -from discord import Colour, Embed, User -from discord.ext.commands import ( - Command, Context, clean_content, command, group -) +from discord import Colour, Embed, Member, User +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 from bot.pagination import LinePaginator log = logging.getLogger(__name__) -class Alias: - """ - Aliases for more used commands - """ +class Alias (Cog): + """Aliases for commonly used commands.""" - def __init__(self, bot): + def __init__(self, bot: Bot): self.bot = bot - async def invoke(self, ctx, cmd_name, *args, **kwargs): - """ - Invokes a command with args and kwargs. - Fail early through `command.can_run`, and logs warnings. - - :param ctx: Context instance for command call - :param cmd_name: Name of command/subcommand to be invoked - :param args: args to be passed to the command - :param kwargs: kwargs to be passed to the command - :return: None - """ - + async def invoke(self, ctx: Context, cmd_name: str, *args, **kwargs) -> None: + """Invokes a command with args and kwargs.""" log.debug(f"{cmd_name} was invoked through an alias") cmd = self.bot.get_command(cmd_name) if not cmd: @@ -44,9 +32,8 @@ class Alias: await ctx.invoke(cmd, *args, **kwargs) @command(name='aliases') - async def aliases_command(self, ctx): + async def aliases_command(self, ctx: Context) -> None: """Show configured aliases on the bot.""" - embed = Embed( title='Configured aliases', colour=Colour.blue() @@ -62,118 +49,98 @@ class Alias: ) @command(name="resources", aliases=("resource",), hidden=True) - async def site_resources_alias(self, ctx): - """ - Alias for invoking <prefix>site resources. - """ - + async def site_resources_alias(self, ctx: Context) -> None: + """Alias for invoking <prefix>site resources.""" await self.invoke(ctx, "site resources") @command(name="watch", hidden=True) - async def bigbrother_watch_alias( - self, ctx: Context, user: User, *, reason: str - ): - """ - Alias for invoking <prefix>bigbrother watch [user] [reason]. - """ - + async def bigbrother_watch_alias(self, ctx: Context, user: Union[Member, User, proxy_user], *, reason: str) -> None: + """Alias for invoking <prefix>bigbrother watch [user] [reason].""" await self.invoke(ctx, "bigbrother watch", user, reason=reason) @command(name="unwatch", hidden=True) - async def bigbrother_unwatch_alias(self, ctx, user: User, *, reason: str): - """ - Alias for invoking <prefix>bigbrother unwatch [user] [reason]. - """ - + async def bigbrother_unwatch_alias(self, ctx: Context, user: Union[User, proxy_user], *, reason: str) -> None: + """Alias for invoking <prefix>bigbrother unwatch [user] [reason].""" await self.invoke(ctx, "bigbrother unwatch", user, reason=reason) @command(name="home", hidden=True) - async def site_home_alias(self, ctx): - """ - Alias for invoking <prefix>site home. - """ - + async def site_home_alias(self, ctx: Context) -> None: + """Alias for invoking <prefix>site home.""" await self.invoke(ctx, "site home") @command(name="faq", hidden=True) - async def site_faq_alias(self, ctx): - """ - Alias for invoking <prefix>site faq. - """ - + async def site_faq_alias(self, ctx: Context) -> None: + """Alias for invoking <prefix>site faq.""" await self.invoke(ctx, "site faq") - @command(name="reload", hidden=True) - async def cogs_reload_alias(self, ctx, *, cog_name: str): - """ - Alias for invoking <prefix>cogs reload [cog_name]. - - cog_name: str - name of the cog to be reloaded. - """ + @command(name="rules", hidden=True) + async def site_rules_alias(self, ctx: Context) -> None: + """Alias for invoking <prefix>site rules.""" + await self.invoke(ctx, "site rules") + @command(name="reload", hidden=True) + async def cogs_reload_alias(self, ctx: Context, *, cog_name: str) -> None: + """Alias for invoking <prefix>cogs reload [cog_name].""" await self.invoke(ctx, "cogs reload", cog_name) @command(name="defon", hidden=True) - async def defcon_enable_alias(self, ctx): - """ - Alias for invoking <prefix>defcon enable. - """ - + async def defcon_enable_alias(self, ctx: Context) -> None: + """Alias for invoking <prefix>defcon enable.""" await self.invoke(ctx, "defcon enable") @command(name="defoff", hidden=True) - async def defcon_disable_alias(self, ctx): - """ - Alias for invoking <prefix>defcon disable. - """ - + async def defcon_disable_alias(self, ctx: Context) -> None: + """Alias for invoking <prefix>defcon disable.""" await self.invoke(ctx, "defcon disable") @command(name="exception", hidden=True) - async def tags_get_traceback_alias(self, ctx): - """ - Alias for invoking <prefix>tags get traceback. - """ - - await self.invoke(ctx, "tags get traceback") + async def tags_get_traceback_alias(self, ctx: Context) -> None: + """Alias for invoking <prefix>tags get traceback.""" + await self.invoke(ctx, "tags get", tag_name="traceback") @group(name="get", aliases=("show", "g"), hidden=True, invoke_without_command=True) - async def get_group_alias(self, ctx): - """ - Group for reverse aliases for commands like `tags get`, - allowing for `get tags` or `get docs`. - """ - + async def get_group_alias(self, ctx: Context) -> None: + """Group for reverse aliases for commands like `tags get`, allowing for `get tags` or `get docs`.""" pass @get_group_alias.command(name="tags", aliases=("tag", "t"), hidden=True) async def tags_get_alias( self, ctx: Context, *, tag_name: TagNameConverter = None - ): + ) -> None: """ Alias for invoking <prefix>tags get [tag_name]. tag_name: str - tag to be viewed. """ - - await self.invoke(ctx, "tags get", tag_name) + await self.invoke(ctx, "tags get", tag_name=tag_name) @get_group_alias.command(name="docs", aliases=("doc", "d"), hidden=True) async def docs_get_alias( self, ctx: Context, symbol: clean_content = None - ): - """ - Alias for invoking <prefix>docs get [symbol]. + ) -> None: + """Alias for invoking <prefix>docs get [symbol].""" + await self.invoke(ctx, "docs get", symbol) - symbol: str - name of doc to be viewed. - """ + @command(name="nominate", hidden=True) + async def nomination_add_alias(self, ctx: Context, user: Union[Member, User, proxy_user], *, reason: str) -> None: + """Alias for invoking <prefix>talentpool add [user] [reason].""" + await self.invoke(ctx, "talentpool add", user, reason=reason) - await self.invoke(ctx, "docs get", symbol) + @command(name="unnominate", hidden=True) + async def nomination_end_alias(self, ctx: Context, user: Union[User, proxy_user], *, reason: str) -> None: + """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): +def setup(bot: Bot) -> None: + """Alias cog load.""" bot.add_cog(Alias(bot)) log.info("Cog loaded: Alias") diff --git a/bot/cogs/antispam.py b/bot/cogs/antispam.py index 03551e806..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,20 +36,104 @@ RULE_FUNCTION_MAPPING = { } -class AntiSpam: - def __init__(self, bot: Bot): +@dataclass +class DeletionContext: + """Represents a Deletion Context for a single spam event.""" + + 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: + """Allows for easy access of the ModLog cog.""" return self.bot.get_cog("ModLog") - async def on_ready(self): - role_id = AntiSpamConfig.punishment['role_id'] - self.muted_role = Object(role_id) + @Cog.listener() + async def on_ready(self) -> None: + """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 - async def on_message(self, message: Message): + @Cog.listener() + async def on_message(self, message: Message) -> None: + """Applies the antispam rules to each received message.""" if ( not message.guild or message.guild.id != GuildConfig.id @@ -58,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: @@ -86,60 +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): - # 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) - 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]): - # Is deletion of offending messages actually enabled? + async def maybe_delete_messages(self, channel: TextChannel, messages: List[Message]) -> None: + """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] @@ -150,26 +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(): - 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): - validate_config() - bot.add_cog(AntiSpam(bot)) +def setup(bot: Bot) -> None: + """Antispam cog load.""" + validation_errors = validate_config() + bot.add_cog(AntiSpam(bot, validation_errors)) log.info("Cog loaded: AntiSpam") diff --git a/bot/cogs/bigbrother.py b/bot/cogs/bigbrother.py deleted file mode 100644 index 97655812b..000000000 --- a/bot/cogs/bigbrother.py +++ /dev/null @@ -1,501 +0,0 @@ -import asyncio -import logging -import re -from collections import defaultdict, deque -from time import strptime, struct_time -from typing import List, NamedTuple, Optional, Union - -from aiohttp import ClientError -from discord import Color, Embed, Guild, Member, Message, TextChannel, User, errors -from discord.ext.commands import Bot, Context, command, group - -from bot.constants import ( - BigBrother as BigBrotherConfig, Channels, Emojis, - Guild as GuildConfig, Keys, - MODERATION_ROLES, STAFF_ROLES, URLs -) -from bot.decorators import with_role -from bot.pagination import LinePaginator -from bot.utils import messages -from bot.utils.moderation import post_infraction -from bot.utils.time import parse_rfc1123, time_since - -log = logging.getLogger(__name__) - -URL_RE = re.compile(r"(https?://[^\s]+)") - - -class WatchInformation(NamedTuple): - reason: str - actor_id: Optional[int] - inserted_at: Optional[str] - - -class BigBrother: - """User monitoring to assist with moderation.""" - - HEADERS = {'X-API-Key': Keys.site_api} - - def __init__(self, bot: Bot): - self.bot = bot - self.watched_users = {} # { user_id: log_channel_id } - self.watch_reasons = {} # { user_id: watch_reason } - self.channel_queues = defaultdict(lambda: defaultdict(deque)) # { user_id: { channel_id: queue(messages) } - self.last_log = [None, None, 0] # [user_id, channel_id, message_count] - self.consuming = False - self.consume_task = None - self.infraction_watch_prefix = "bb watch: " # Please do not change or we won't be able to find old reasons - self.nomination_prefix = "Helper nomination: " - - self.bot.loop.create_task(self.get_watched_users()) - - def update_cache(self, api_response: List[dict]): - """ - Updates the internal cache of watched users from the given `api_response`. - This function will only add (or update) existing keys, it will not delete - keys that were not present in the API response. - A user is only added if the bot can find a channel - with the given `channel_id` in its channel cache. - """ - - for entry in api_response: - user_id = int(entry['user_id']) - channel_id = int(entry['channel_id']) - channel = self.bot.get_channel(channel_id) - - if channel is not None: - self.watched_users[user_id] = channel - else: - log.error( - f"Site specified to relay messages by `{user_id}` in `{channel_id}`, " - "but the given channel could not be found. Ignoring." - ) - - async def get_watched_users(self): - """Retrieves watched users from the API.""" - - await self.bot.wait_until_ready() - async with self.bot.http_session.get(URLs.site_bigbrother_api, headers=self.HEADERS) as response: - data = await response.json() - self.update_cache(data) - - async def update_watched_users(self): - async with self.bot.http_session.get(URLs.site_bigbrother_api, headers=self.HEADERS) as response: - if response.status == 200: - data = await response.json() - self.update_cache(data) - log.trace("Updated Big Brother watchlist cache") - return True - else: - return False - - async def get_watch_information(self, user_id: int, prefix: str) -> WatchInformation: - """ Fetches and returns the latest watch reason for a user using the infraction API """ - - re_bb_watch = rf"^{prefix}" - user_id = str(user_id) - - try: - response = await self.bot.http_session.get( - URLs.site_infractions_user_type.format( - user_id=user_id, - infraction_type="note", - ), - params={"search": re_bb_watch, "hidden": "True", "active": "False"}, - headers=self.HEADERS - ) - infraction_list = await response.json() - except ClientError: - log.exception(f"Failed to retrieve bb watch reason for {user_id}.") - return WatchInformation(reason="(error retrieving bb reason)", actor_id=None, inserted_at=None) - - if infraction_list: - # Get the latest watch reason - latest_reason_infraction = max(infraction_list, key=self._parse_infraction_time) - - # Get the actor of the watch/nominate action - actor_id = int(latest_reason_infraction["actor"]["user_id"]) - - # Get the date the watch was set - date = latest_reason_infraction["inserted_at"] - - # Get the latest reason without the prefix - latest_reason = latest_reason_infraction['reason'][len(prefix):] - - log.trace(f"The latest bb watch reason for {user_id}: {latest_reason}") - return WatchInformation(reason=latest_reason, actor_id=actor_id, inserted_at=date) - - log.trace(f"No bb watch reason found for {user_id}; returning defaults") - return WatchInformation(reason="(no reason specified)", actor_id=None, inserted_at=None) - - @staticmethod - def _parse_infraction_time(infraction: dict) -> struct_time: - """ - Helper function that retrieves the insertion time from the infraction dictionary, - converts the retrieved RFC1123 date_time string to a time object, and returns it - so infractions can be sorted by their insertion time. - """ - - date_string = infraction["inserted_at"] - return strptime(date_string, "%a, %d %b %Y %H:%M:%S %Z") - - async def on_member_ban(self, guild: Guild, user: Union[User, Member]): - if guild.id == GuildConfig.id and user.id in self.watched_users: - url = f"{URLs.site_bigbrother_api}?user_id={user.id}" - channel = self.watched_users[user.id] - - async with self.bot.http_session.delete(url, headers=self.HEADERS) as response: - del self.watched_users[user.id] - del self.channel_queues[user.id] - del self.watch_reasons[user.id] - if response.status == 204: - await channel.send( - f"{Emojis.bb_message}:hammer: {user} got banned, so " - f"`BigBrother` will no longer relay their messages to {channel}" - ) - - else: - data = await response.json() - reason = data.get('error_message', "no message provided") - await channel.send( - f"{Emojis.bb_message}:x: {user} got banned, but trying to remove them from" - f"BigBrother's user dictionary on the API returned an error: {reason}" - ) - - async def on_message(self, msg: Message): - """Queues up messages sent by watched users.""" - - if msg.author.id in self.watched_users: - if not self.consuming: - self.consume_task = self.bot.loop.create_task(self.consume_messages()) - - if self.consuming and self.consume_task.done(): - # This should never happen, so something went wrong - - log.error("The consume_task has finished, but did not reset the self.consuming boolean") - e = self.consume_task.exception() - if e: - log.exception("The Exception for the Task:", exc_info=e) - else: - log.error("However, an Exception was not found.") - - self.consume_task = self.bot.loop.create_task(self.consume_messages()) - - log.trace(f"Received message: {msg.content} ({len(msg.attachments)} attachments)") - self.channel_queues[msg.author.id][msg.channel.id].append(msg) - - async def consume_messages(self): - """Consumes the message queues to log watched users' messages.""" - - if not self.consuming: - self.consuming = True - log.trace("Sleeping before consuming...") - await asyncio.sleep(BigBrotherConfig.log_delay) - - log.trace("Begin consuming messages.") - channel_queues = self.channel_queues.copy() - self.channel_queues.clear() - for user_id, queues in channel_queues.items(): - for _, queue in queues.items(): - channel = self.watched_users[user_id] - while queue: - msg = queue.popleft() - log.trace(f"Consuming message: {msg.clean_content} ({len(msg.attachments)} attachments)") - - self.last_log[2] += 1 # Increment message count. - await self.send_header(msg, channel) - await self.log_message(msg, channel) - - if self.channel_queues: - log.trace("Queue not empty; continue consumption.") - self.consume_task = self.bot.loop.create_task(self.consume_messages()) - else: - log.trace("Done consuming messages.") - self.consuming = False - - async def send_header(self, message: Message, destination: TextChannel): - """ - Sends a log message header to the given channel. - - A header is only sent if the user or channel are different than the previous, or if the configured message - limit for a single header has been exceeded. - - :param message: the first message in the queue - :param destination: the channel in which to send the header - """ - - last_user, last_channel, msg_count = self.last_log - limit = BigBrotherConfig.header_message_limit - - # Send header if user/channel are different or if message limit exceeded. - if message.author.id != last_user or message.channel.id != last_channel or msg_count > limit: - # Retrieve watch reason from API if it's not already in the cache - if message.author.id not in self.watch_reasons: - log.trace(f"No watch information for {message.author.id} found in cache; retrieving from API") - if destination == self.bot.get_channel(Channels.talent_pool): - prefix = self.nomination_prefix - else: - prefix = self.infraction_watch_prefix - user_watch_information = await self.get_watch_information(message.author.id, prefix) - self.watch_reasons[message.author.id] = user_watch_information - - self.last_log = [message.author.id, message.channel.id, 0] - - # Get reason, actor, inserted_at - reason, actor_id, inserted_at = self.watch_reasons[message.author.id] - - # Setting up the default author_field - author_field = message.author.nick or message.author.name - - # When we're dealing with a talent-pool header, add nomination info to the author field - if destination == self.bot.get_channel(Channels.talent_pool): - log.trace("We're sending a header to the talent-pool; let's add nomination info") - # If a reason was provided, both should be known - if actor_id and inserted_at: - # Parse actor name - guild: GuildConfig = self.bot.get_guild(GuildConfig.id) - actor_as_member = guild.get_member(actor_id) - actor = actor_as_member.nick or actor_as_member.name - - # Get time delta since insertion - date_time = parse_rfc1123(inserted_at).replace(tzinfo=None) - time_delta = time_since(date_time, precision="minutes", max_units=1) - - # Adding nomination info to author_field - author_field = f"{author_field} (nominated {time_delta} by {actor})" - else: - if inserted_at: - # Get time delta since insertion - date_time = parse_rfc1123(inserted_at).replace(tzinfo=None) - time_delta = time_since(date_time, precision="minutes", max_units=1) - - author_field = f"{author_field} (added {time_delta})" - - embed = Embed(description=f"{message.author.mention} in [#{message.channel.name}]({message.jump_url})") - embed.set_author(name=author_field, icon_url=message.author.avatar_url) - embed.set_footer(text=f"Reason: {reason}") - await destination.send(embed=embed) - - @staticmethod - async def log_message(message: Message, destination: TextChannel): - """ - Logs a watched user's message in the given channel. - - Attachments are also sent. All non-image or non-video URLs are put in inline code blocks to prevent preview - embeds from being automatically generated. - - :param message: the message to log - :param destination: the channel in which to log the message - """ - - content = message.clean_content - if content: - # Put all non-media URLs in inline code blocks. - media_urls = {embed.url for embed in message.embeds if embed.type in ("image", "video")} - for url in URL_RE.findall(content): - if url not in media_urls: - content = content.replace(url, f"`{url}`") - - await destination.send(content) - - try: - await messages.send_attachments(message, destination) - except (errors.Forbidden, errors.NotFound): - e = Embed( - description=":x: **This message contained an attachment, but it could not be retrieved**", - color=Color.red() - ) - await destination.send(embed=e) - - async def _watch_user(self, ctx: Context, user: User, reason: str, channel_id: int): - post_data = { - 'user_id': str(user.id), - 'channel_id': str(channel_id) - } - - async with self.bot.http_session.post( - URLs.site_bigbrother_api, - headers=self.HEADERS, - json=post_data - ) as response: - if response.status == 204: - if channel_id == Channels.talent_pool: - await ctx.send(f":ok_hand: added {user} to the <#{channel_id}>!") - else: - await ctx.send(f":ok_hand: will now relay messages sent by {user} in <#{channel_id}>") - - channel = self.bot.get_channel(channel_id) - if channel is None: - log.error( - f"could not update internal cache, failed to find a channel with ID {channel_id}" - ) - else: - self.watched_users[user.id] = channel - - # Add a note (shadow warning) with the reason for watching - await post_infraction(ctx, user, type="warning", reason=reason, hidden=True) - else: - data = await response.json() - error_reason = data.get('error_message', "no message provided") - await ctx.send(f":x: the API returned an error: {error_reason}") - - @group(name='bigbrother', aliases=('bb',), invoke_without_command=True) - @with_role(*MODERATION_ROLES) - async def bigbrother_group(self, ctx: Context): - """Monitor users, NSA-style.""" - - await ctx.invoke(self.bot.get_command("help"), "bigbrother") - - @bigbrother_group.command(name='watched', aliases=('all',)) - @with_role(*MODERATION_ROLES) - async def watched_command(self, ctx: Context, from_cache: bool = True): - """ - Shows all users that are currently monitored and in which channel. - By default, the users are returned from the cache. - If this is not desired, `from_cache` can be given as a falsy value, e.g. e.g. 'no'. - """ - if not from_cache: - updated = await self.update_watched_users() - if not updated: - await ctx.send(f":x: Failed to update cache: non-200 response from the API") - return - title = "Watched users (updated cache)" - else: - title = "Watched users (from cache)" - - lines = tuple( - f"• <@{user_id}> in <#{self.watched_users[user_id].id}>" - for user_id in self.watched_users - ) - await LinePaginator.paginate( - lines or ("There's nothing here yet.",), - ctx, - Embed(title=title, color=Color.blue()), - empty=False - ) - - @bigbrother_group.command(name='watch', aliases=('w',)) - @with_role(*MODERATION_ROLES) - async def watch_command(self, ctx: Context, user: User, *, reason: str): - """ - Relay messages sent by the given `user` to the `#big-brother-logs` channel - - A `reason` for watching is required, which is added for the user to be watched as a - note (aka: shadow warning) - """ - - # Update cache to avoid double watching of a user - await self.update_watched_users() - - if user.id in self.watched_users: - message = f":x: User is already being watched in {self.watched_users[user.id].name}" - await ctx.send(message) - return - - channel_id = Channels.big_brother_logs - - reason = f"{self.infraction_watch_prefix}{reason}" - - await self._watch_user(ctx, user, reason, channel_id) - - @bigbrother_group.command(name='unwatch', aliases=('uw',)) - @with_role(*MODERATION_ROLES) - async def unwatch_command(self, ctx: Context, user: User, *, reason: str): - """ - Stop relaying messages by the given `user`. - - A `reason` for unwatching is required, which will be added as a note to the user. - """ - - url = f"{URLs.site_bigbrother_api}?user_id={user.id}" - async with self.bot.http_session.delete(url, headers=self.HEADERS) as response: - if response.status == 204: - await ctx.send(f":ok_hand: will no longer relay messages sent by {user}") - - if user.id in self.watched_users: - channel = self.watched_users[user.id] - - del self.watched_users[user.id] - if user.id in self.channel_queues: - del self.channel_queues[user.id] - if user.id in self.watch_reasons: - del self.watch_reasons[user.id] - else: - channel = None - log.warning(f"user {user.id} was unwatched but was not found in the cache") - - reason = f"Unwatched ({channel.name if channel else 'unknown channel'}): {reason}" - await post_infraction(ctx, user, type="warning", reason=reason, hidden=True) - - else: - data = await response.json() - reason = data.get('error_message', "no message provided") - await ctx.send(f":x: the API returned an error: {reason}") - - @bigbrother_group.command(name='nominate', aliases=('n',)) - @with_role(*MODERATION_ROLES) - async def nominate_command(self, ctx: Context, user: User, *, reason: str): - """ - Nominates a user for the helper role by adding them to the talent-pool channel - - A `reason` for the nomination is required and will be added as a note to - the user's records. - """ - - # Note: This function is called from HelperNomination.nominate_command so that the - # !nominate command does not show up under "BigBrother" in the help embed, but under - # the header HelperNomination for users with the helper role. - - member = ctx.guild.get_member(user.id) - - if member and any(role.id in STAFF_ROLES for role in member.roles): - await ctx.send(f":x: {user.mention} is already a staff member!") - return - - channel_id = Channels.talent_pool - - # Update watch cache to avoid overwriting active nomination reason - await self.update_watched_users() - - if user.id in self.watched_users: - if self.watched_users[user.id].id == Channels.talent_pool: - prefix = "Additional nomination: " - else: - # If the user is being watched in big-brother, don't add them to talent-pool - message = ( - f":x: {user.mention} can't be added to the talent-pool " - "as they are currently being watched in big-brother." - ) - await ctx.send(message) - return - else: - prefix = self.nomination_prefix - - reason = f"{prefix}{reason}" - - await self._watch_user(ctx, user, reason, channel_id) - - -class HelperNomination: - def __init__(self, bot): - self.bot = bot - - @command(name='nominate', aliases=('n',)) - @with_role(*STAFF_ROLES) - async def nominate_command(self, ctx: Context, user: User, *, reason: str): - """ - Nominates a user for the helper role by adding them to the talent-pool channel - - A `reason` for the nomination is required and will be added as a note to - the user's records. - """ - - cmd = self.bot.get_command("bigbrother nominate") - - await ctx.invoke(cmd, user, reason=reason) - - -def setup(bot: Bot): - bot.add_cog(BigBrother(bot)) - bot.add_cog(HelperNomination(bot)) - log.info("Cog loaded: BigBrother") diff --git a/bot/cogs/bot.py b/bot/cogs/bot.py index 828e2514c..324d2ccd3 100644 --- a/bot/cogs/bot.py +++ b/bot/cogs/bot.py @@ -2,24 +2,22 @@ import ast import logging import re import time +from typing import Optional, Tuple 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: - """ - Bot information commands - """ + +class Bot(Cog): + """Bot information commands.""" def __init__(self, bot: Bot): self.bot = bot @@ -32,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, } @@ -46,29 +46,23 @@ class Bot: @group(invoke_without_command=True, name="bot", hidden=True) @with_role(Roles.verified) - async def bot_group(self, ctx: Context): - """ - Bot informational commands - """ - + 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): - """ - Get information about the bot - """ - + 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 ) @@ -77,24 +71,18 @@ class Bot: @command(name='echo', aliases=('print',)) @with_role(*MODERATION_ROLES) - async def echo_command(self, ctx: Context, *, text: str): - """ - Send the input verbatim to the current channel - """ - + async def echo_command(self, ctx: Context, *, text: str) -> None: + """Send the input verbatim to the current channel.""" await ctx.send(text) @command(name='embed') @with_role(*MODERATION_ROLES) - async def embed_command(self, ctx: Context, *, text: str): - """ - Send the input within an embed to the current channel - """ - + async def embed_command(self, ctx: Context, *, text: str) -> None: + """Send the input within an embed to the current channel.""" embed = Embed(description=text) await ctx.send(embed=embed) - def codeblock_stripping(self, msg: str, bad_ticks: bool): + def codeblock_stripping(self, msg: str, bad_ticks: bool) -> Optional[Tuple[Tuple[str, ...], str]]: """ Strip msg in order to find Python code. @@ -163,15 +151,10 @@ class Bot: log.trace(f"Returning message.\n\n{content}\n\n") return (content,), repl_code - def fix_indentation(self, msg: str): - """ - Attempts to fix badly indented code. - """ - - def unindent(code, skip_spaces=0): - """ - Unindents all code down to the number of spaces given ins skip_spaces - """ + def fix_indentation(self, msg: str) -> str: + """Attempts to fix badly indented code.""" + def unindent(code, skip_spaces: int = 0) -> str: + """Unindents all code down to the number of spaces given in skip_spaces.""" final = "" current = code[0] leading_spaces = 0 @@ -207,11 +190,13 @@ class Bot: msg = f"{first_line}\n{unindent(code, 4)}" return msg - def repl_stripping(self, msg: str): + def repl_stripping(self, msg: str) -> Tuple[str, bool]: """ Strip msg in order to extract Python code out of REPL output. Tries to strip out REPL Python code out of msg and returns the stripped msg. + + Returns True for the boolean if REPL code was found in the input msg. """ final = "" for line in msg.splitlines(keepends=True): @@ -225,7 +210,8 @@ class Bot: log.trace(f"Found REPL code in \n\n{msg}\n\n") return final.rstrip(), True - def has_bad_ticks(self, msg: Message): + def has_bad_ticks(self, msg: Message) -> bool: + """Check to see if msg contains ticks that aren't '`'.""" not_backticks = [ "'''", '"""', "\u00b4\u00b4\u00b4", "\u2018\u2018\u2018", "\u2019\u2019\u2019", "\u2032\u2032\u2032", "\u201c\u201c\u201c", "\u201d\u201d\u201d", "\u2033\u2033\u2033", @@ -234,13 +220,14 @@ class Bot: return msg.content[:3] in not_backticks - async def on_message(self, msg: Message): - """ - Detect poorly formatted Python code and send the user - a helpful message explaining how to do properly - formatted Python syntax highlighting codeblocks. + @Cog.listener() + async def on_message(self, msg: Message) -> None: """ + Detect poorly formatted Python code in new messages. + If poorly formatted code is detected, send the user a helpful message explaining how to do + properly formatted Python syntax highlighting codeblocks. + """ parse_codeblock = ( ( msg.channel.id in self.channel_cooldowns @@ -252,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] @@ -277,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```" ) @@ -319,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```" ) @@ -355,7 +345,9 @@ class Bot: f"The message that was posted was:\n\n{msg.content}\n\n" ) - async def on_raw_message_edit(self, payload: RawMessageUpdateEvent): + @Cog.listener() + async def on_raw_message_edit(self, payload: RawMessageUpdateEvent) -> None: + """Check to see if an edited message (previously called out) still contains poorly formatted code.""" if ( # Checks to see if the message was called out by the bot payload.message_id not in self.codeblock_message_ids @@ -368,19 +360,20 @@ 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.") -def setup(bot): +def setup(bot: Bot) -> None: + """Bot cog load.""" bot.add_cog(Bot(bot)) log.info("Cog loaded: Bot") diff --git a/bot/cogs/clean.py b/bot/cogs/clean.py index b34d1118b..1c0c9a7a8 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,19 +16,15 @@ 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. + A cog that allows messages to be deleted in bulk, while applying various filters. - You can delete messages sent by a specific user, - messages sent by bots, all messages, or messages - that match a specific regular expression. + You can delete messages sent by a specific user, messages sent by bots, all messages, or messages that match a + specific regular expression. - The deleted messages are saved and uploaded - to the database via an API endpoint, and a URL is - returned which can be used to view the messages - in the Discord dark theme style. + The deleted messages are saved and uploaded to the database via an API endpoint, and a URL is returned which can be + used to view the messages in the Discord dark theme style. """ def __init__(self, bot: Bot): @@ -37,44 +33,25 @@ class Clean: @property def mod_log(self) -> ModLog: + """Get currently loaded ModLog cog instance.""" return self.bot.get_cog("ModLog") async def _clean_messages( self, amount: int, ctx: Context, bots_only: bool = False, user: User = None, regex: Optional[str] = None - ): - """ - A helper function that does the actual message cleaning. - - :param bots_only: Set this to True if you only want to delete bot messages. - :param user: Specify a user and it will only delete messages by this user. - :param regular_expression: Specify a regular expression and it will only - delete messages that match this. - """ - + ) -> None: + """A helper function that does the actual message cleaning.""" def predicate_bots_only(message: Message) -> bool: - """ - Returns true if the message was sent by a bot - """ - + """Return True if the message was sent by a bot.""" return message.author.bot def predicate_specific_user(message: Message) -> bool: - """ - Return True if the message was sent by the - user provided in the _clean_messages call. - """ - + """Return True if the message was sent by the user provided in the _clean_messages call.""" return message.author == user - def predicate_regex(message: Message): - """ - Returns True if the regex provided in the - _clean_messages matches the message content - or any embed attributes the message may have. - """ - + def predicate_regex(message: Message) -> bool: + """Check if the regex provided in _clean_messages matches the message content or any embed attributes.""" content = [message.content] # Add the content for all embed attributes @@ -133,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: @@ -165,7 +143,7 @@ class Clean: # Reverse the list to restore chronological order if messages: messages = list(reversed(messages)) - log_url = await self.mod_log.upload_log(messages) + log_url = await self.mod_log.upload_log(messages, ctx.author.id) else: # Can't build an embed, nothing to clean! embed = Embed( @@ -191,61 +169,38 @@ class Clean: @group(invoke_without_command=True, name="clean", hidden=True) @with_role(*MODERATION_ROLES) - async def clean_group(self, ctx: Context): - """ - Commands for cleaning messages in channels - """ - + async def clean_group(self, ctx: Context) -> None: + """Commands for cleaning messages in channels.""" await ctx.invoke(self.bot.get_command("help"), "clean") @clean_group.command(name="user", aliases=["users"]) @with_role(*MODERATION_ROLES) - async def clean_user(self, ctx: Context, user: User, amount: int = 10): - """ - Delete messages posted by the provided user, - and stop cleaning after traversing `amount` messages. - """ - + async def clean_user(self, ctx: Context, user: User, amount: int = 10) -> None: + """Delete messages posted by the provided user, stop cleaning after traversing `amount` messages.""" await self._clean_messages(amount, ctx, user=user) @clean_group.command(name="all", aliases=["everything"]) @with_role(*MODERATION_ROLES) - async def clean_all(self, ctx: Context, amount: int = 10): - """ - Delete all messages, regardless of poster, - and stop cleaning after traversing `amount` messages. - """ - + async def clean_all(self, ctx: Context, amount: int = 10) -> None: + """Delete all messages, regardless of poster, stop cleaning after traversing `amount` messages.""" await self._clean_messages(amount, ctx) @clean_group.command(name="bots", aliases=["bot"]) @with_role(*MODERATION_ROLES) - async def clean_bots(self, ctx: Context, amount: int = 10): - """ - Delete all messages posted by a bot, - and stop cleaning after traversing `amount` messages. - """ - + async def clean_bots(self, ctx: Context, amount: int = 10) -> None: + """Delete all messages posted by a bot, stop cleaning after traversing `amount` messages.""" await self._clean_messages(amount, ctx, bots_only=True) @clean_group.command(name="regex", aliases=["word", "expression"]) @with_role(*MODERATION_ROLES) - async def clean_regex(self, ctx: Context, regex, amount: int = 10): - """ - Delete all messages that match a certain regex, - and stop cleaning after traversing `amount` messages. - """ - + async def clean_regex(self, ctx: Context, regex: str, amount: int = 10) -> None: + """Delete all messages that match a certain regex, stop cleaning after traversing `amount` messages.""" await self._clean_messages(amount, ctx, regex=regex) @clean_group.command(name="stop", aliases=["cancel", "abort"]) @with_role(*MODERATION_ROLES) - async def clean_cancel(self, ctx: Context): - """ - If there is an ongoing cleaning process, - attempt to immediately cancel it. - """ - + async def clean_cancel(self, ctx: Context) -> None: + """If there is an ongoing cleaning process, attempt to immediately cancel it.""" self.cleaning = False embed = Embed( @@ -255,6 +210,7 @@ class Clean: await ctx.send(embed=embed, delete_after=10) -def setup(bot): +def setup(bot: Bot) -> None: + """Clean cog load.""" bot.add_cog(Clean(bot)) log.info("Cog loaded: Clean") diff --git a/bot/cogs/cogs.py b/bot/cogs/cogs.py index b82273978..9c50c7dd8 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,10 +15,8 @@ log = logging.getLogger(__name__) KEEP_LOADED = ["bot.cogs.cogs", "bot.cogs.modlog"] -class Cogs: - """ - Cog management commands - """ +class Cogs(Cog): + """Cog management commands.""" def __init__(self, bot: Bot): self.bot = bot @@ -37,22 +35,20 @@ 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) - async def cogs_group(self, ctx: Context): + @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) - async def load_command(self, ctx: Context, cog: str): + @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 + Load up an unloaded cog, given the module containing it. You can specify the cog name for any cogs that are placed directly within `!cogs`, or specify the entire module directly. """ - cog = cog.lower() embed = Embed() @@ -60,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 ) @@ -84,7 +80,7 @@ class Cogs: except Exception as e: log.exception(f"{ctx.author} requested we load the '{cog}' cog, " "but the loading failed") - embed.description = f"Failed to load cog: {cog}\n\n```{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}" @@ -96,15 +92,14 @@ class Cogs: await ctx.send(embed=embed) @cogs_group.command(name='unload', aliases=('ul',)) - @with_role(*MODERATION_ROLES, Roles.devops) - async def unload_command(self, ctx: Context, cog: str): + @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 + Unload an already-loaded cog, given the module containing it. You can specify the cog name for any cogs that are placed directly within `!cogs`, or specify the entire module directly. """ - cog = cog.lower() embed = Embed() @@ -112,7 +107,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 ) @@ -147,10 +142,10 @@ class Cogs: await ctx.send(embed=embed) @cogs_group.command(name='reload', aliases=('r',)) - @with_role(*MODERATION_ROLES, Roles.devops) - async def reload_command(self, ctx: Context, cog: str): + @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 + Reload an unloaded cog, given the module containing it. You can specify the cog name for any cogs that are placed directly within `!cogs`, or specify the entire module directly. @@ -158,7 +153,6 @@ class Cogs: If you specify "*" as the cog, every cog currently loaded will be unloaded, and then every cog present in the bot/cogs directory will be loaded. """ - cog = cog.lower() embed = Embed() @@ -166,7 +160,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 ) @@ -198,7 +192,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 @@ -206,7 +200,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 @@ -219,18 +213,19 @@ 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}") - return await LinePaginator.paginate(lines, ctx, embed, empty=False) + await LinePaginator.paginate(lines, ctx, embed, empty=False) + return elif full_cog in self.bot.extensions: try: @@ -251,14 +246,13 @@ class Cogs: await ctx.send(embed=embed) @cogs_group.command(name='list', aliases=('all',)) - @with_role(*MODERATION_ROLES, Roles.devops) - async def list_command(self, ctx: Context): + @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. Gray indicates that the cog is unloaded. Green indicates that the cog is currently loaded. """ - embed = Embed() lines = [] cogs = {} @@ -266,7 +260,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 ) @@ -298,6 +292,7 @@ class Cogs: await LinePaginator.paginate(lines, ctx, embed, max_size=300, empty=False) -def setup(bot): +def setup(bot: Bot) -> None: + """Cogs cog load.""" bot.add_cog(Cogs(bot)) log.info("Cog loaded: Cogs") diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py index f07d9df9f..048d8a683 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, URLs +from bot.constants import Channels, Colours, Emojis, Event, Icons, Roles from bot.decorators import with_role log = logging.getLogger(__name__) @@ -24,29 +24,29 @@ will be resolved soon. In the meantime, please feel free to peruse the resources BASE_CHANNEL_TOPIC = "Python Discord Defense Mechanism" -class Defcon: - """Time-sensitive server defense mechanisms""" +class Defcon(Cog): + """Time-sensitive server defense mechanisms.""" + days = None # type: timedelta enabled = False # type: bool 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") - async def on_ready(self): + @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.http_session.get( - URLs.site_settings_api, - headers=self.headers, - params={"keys": "defcon_enabled,defcon_days"} - ) - - data = await response.json() + response = await self.bot.api_client.get('bot/bot-settings/defcon') + data = response['data'] except Exception: # Yikes! log.exception("Unable to get DEFCON settings!") @@ -55,9 +55,9 @@ class Defcon: ) else: - if data["defcon_enabled"]: + if data["enabled"]: self.enabled = True - self.days = timedelta(days=data["defcon_days"]) + self.days = timedelta(days=data["days"]) log.warning(f"DEFCON enabled: {self.days.days} days") else: @@ -67,7 +67,9 @@ class Defcon: await self.update_channel_topic() - async def on_member_join(self, member: Member): + @Cog.listener() + async def on_member_join(self, member: Member) -> None: + """If DEFCON is enabled, check newly joining users to see if they meet the account age threshold.""" if self.enabled and self.days.days > 0: now = datetime.utcnow() @@ -100,109 +102,76 @@ class Defcon: @group(name='defcon', aliases=('dc',), invoke_without_command=True) @with_role(Roles.admin, Roles.owner) - async def defcon_group(self, ctx: Context): + async def defcon_group(self, ctx: Context) -> None: """Check the DEFCON status or run a subcommand.""" - await ctx.invoke(self.bot.get_command("help"), "defcon") @defcon_group.command(name='enable', aliases=('on', 'e')) @with_role(Roles.admin, Roles.owner) - async def enable_command(self, ctx: Context): + async def enable_command(self, ctx: Context) -> None: """ Enable DEFCON mode. Useful in a pinch, but be sure you know what you're doing! - Currently, this just adds an account age requirement. Use !defcon days <int> to set how old an account must - be, in days. + Currently, this just adds an account age requirement. Use !defcon days <int> to set how old an account must be, + in days. """ - self.enabled = True try: - response = await self.bot.http_session.put( - URLs.site_settings_api, - headers=self.headers, - json={"defcon_enabled": True} + await self.bot.api_client.put( + 'bot/bot-settings/defcon', + json={ + 'name': 'defcon', + 'data': { + 'enabled': True, + # TODO: retrieve old days count + 'days': 0 + } + } ) - await response.json() except Exception as e: log.exception("Unable to update DEFCON settings.") - await ctx.send( - f"{Emojis.defcon_enabled} DEFCON enabled.\n\n" - "**There was a problem updating the site** - This setting may be reverted when the bot is " - "restarted.\n\n" - f"```py\n{e}\n```" - ) + await ctx.send(self.build_defcon_msg("enabled", e)) + await self.send_defcon_log("enabled", ctx.author, e) - await self.mod_log.send_log_message( - Icons.defcon_enabled, Colours.soft_green, "DEFCON enabled", - f"**Staffer:** {ctx.author.name}#{ctx.author.discriminator} (`{ctx.author.id}`)\n" - f"**Days:** {self.days.days}\n\n" - "**There was a problem updating the site** - This setting may be reverted when the bot is " - "restarted.\n\n" - f"```py\n{e}\n```" - ) else: - await ctx.send(f"{Emojis.defcon_enabled} DEFCON enabled.") - - await self.mod_log.send_log_message( - Icons.defcon_enabled, Colours.soft_green, "DEFCON enabled", - f"**Staffer:** {ctx.author.name}#{ctx.author.discriminator} (`{ctx.author.id}`)\n" - f"**Days:** {self.days.days}\n\n" - ) + await ctx.send(self.build_defcon_msg("enabled")) + await self.send_defcon_log("enabled", ctx.author) await self.update_channel_topic() @defcon_group.command(name='disable', aliases=('off', 'd')) @with_role(Roles.admin, Roles.owner) - async def disable_command(self, ctx: Context): - """ - Disable DEFCON mode. Useful in a pinch, but be sure you know what you're doing! - """ - + async def disable_command(self, ctx: Context) -> None: + """Disable DEFCON mode. Useful in a pinch, but be sure you know what you're doing!""" self.enabled = False try: - response = await self.bot.http_session.put( - URLs.site_settings_api, - headers=self.headers, - json={"defcon_enabled": False} + await self.bot.api_client.put( + 'bot/bot-settings/defcon', + json={ + 'data': { + 'days': 0, + 'enabled': False + }, + 'name': 'defcon' + } ) - - await response.json() except Exception as e: log.exception("Unable to update DEFCON settings.") - await ctx.send( - f"{Emojis.defcon_disabled} DEFCON disabled.\n\n" - "**There was a problem updating the site** - This setting may be reverted when the bot is " - "restarted.\n\n" - f"```py\n{e}\n```" - ) - - await self.mod_log.send_log_message( - Icons.defcon_disabled, Colours.soft_red, "DEFCON disabled", - f"**Staffer:** {ctx.author.name}#{ctx.author.discriminator} (`{ctx.author.id}`)\n" - "**There was a problem updating the site** - This setting may be reverted when the bot is " - "restarted.\n\n" - f"```py\n{e}\n```" - ) + await ctx.send(self.build_defcon_msg("disabled", e)) + await self.send_defcon_log("disabled", ctx.author, e) else: - await ctx.send(f"{Emojis.defcon_disabled} DEFCON disabled.") - - await self.mod_log.send_log_message( - Icons.defcon_disabled, Colours.soft_red, "DEFCON disabled", - f"**Staffer:** {ctx.author.name}#{ctx.author.discriminator} (`{ctx.author.id}`)" - ) + await ctx.send(self.build_defcon_msg("disabled")) + await self.send_defcon_log("disabled", ctx.author) await self.update_channel_topic() @defcon_group.command(name='status', aliases=('s',)) @with_role(Roles.admin, Roles.owner) - async def status_command(self, ctx: Context): - """ - Check the current status of DEFCON mode. - """ - + async def status_command(self, ctx: Context) -> None: + """Check the current status of DEFCON mode.""" embed = Embed( colour=Colour.blurple(), title="DEFCON Status", description=f"**Enabled:** {self.enabled}\n" @@ -213,57 +182,37 @@ class Defcon: @defcon_group.command(name='days') @with_role(Roles.admin, Roles.owner) - async def days_command(self, ctx: Context, days: int): - """ - Set how old an account must be to join the server, in days, with DEFCON mode enabled. - """ - + async def days_command(self, ctx: Context, days: int) -> None: + """Set how old an account must be to join the server, in days, with DEFCON mode enabled.""" self.days = timedelta(days=days) try: - response = await self.bot.http_session.put( - URLs.site_settings_api, - headers=self.headers, - json={"defcon_days": days} + await self.bot.api_client.put( + 'bot/bot-settings/defcon', + json={ + 'data': { + 'days': days, + 'enabled': True + }, + 'name': 'defcon' + } ) - - await response.json() except Exception as e: log.exception("Unable to update DEFCON settings.") - await ctx.send( - f"{Emojis.defcon_updated} DEFCON days updated; accounts must be {days} " - f"days old to join to the server.\n\n" - "**There was a problem updating the site** - This setting may be reverted when the bot is " - "restarted.\n\n" - f"```py\n{e}\n```" - ) - - await self.mod_log.send_log_message( - Icons.defcon_updated, Colour.blurple(), "DEFCON updated", - f"**Staffer:** {ctx.author.name}#{ctx.author.discriminator} (`{ctx.author.id}`)\n" - f"**Days:** {self.days.days}\n\n" - "**There was a problem updating the site** - This setting may be reverted when the bot is " - "restarted.\n\n" - f"```py\n{e}\n```" - ) + await ctx.send(self.build_defcon_msg("updated", e)) + await self.send_defcon_log("updated", ctx.author, e) else: - await ctx.send( - f"{Emojis.defcon_updated} DEFCON days updated; accounts must be {days} days old to join to the server" - ) + await ctx.send(self.build_defcon_msg("updated")) + await self.send_defcon_log("updated", ctx.author) - await self.mod_log.send_log_message( - Icons.defcon_updated, Colour.blurple(), "DEFCON updated", - f"**Staffer:** {ctx.author.name}#{ctx.author.discriminator} (`{ctx.author.id}`)\n" - f"**Days:** {self.days.days}" - ) + # Enable DEFCON if it's not already + if not self.enabled: + self.enabled = True await self.update_channel_topic() - async def update_channel_topic(self): - """ - Update the #defcon channel topic with the current DEFCON status - """ - + async def update_channel_topic(self) -> None: + """Update the #defcon channel topic with the current DEFCON status.""" if self.enabled: day_str = "days" if self.days.days > 1 else "day" new_topic = f"{BASE_CHANNEL_TOPIC}\n(Status: Enabled, Threshold: {self.days.days} {day_str})" @@ -271,10 +220,65 @@ 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 build_defcon_msg(self, change: str, e: Exception = None) -> str: + """ + Build in-channel response string for DEFCON action. + + `change` string may be one of the following: ('enabled', 'disabled', 'updated') + """ + if change.lower() == "enabled": + msg = f"{Emojis.defcon_enabled} DEFCON enabled.\n\n" + elif change.lower() == "disabled": + msg = f"{Emojis.defcon_disabled} DEFCON disabled.\n\n" + elif change.lower() == "updated": + msg = ( + f"{Emojis.defcon_updated} DEFCON days updated; accounts must be {self.days} " + "days old to join the server.\n\n" + ) + + if e: + msg += ( + "**There was a problem updating the site** - This setting may be reverted when the bot restarts.\n\n" + f"```py\n{e}\n```" + ) + + return msg + + async def send_defcon_log(self, change: str, actor: Member, e: Exception = None) -> None: + """ + Send log message for DEFCON action. + + `change` string may be one of the following: ('enabled', 'disabled', 'updated') + """ + log_msg = f"**Staffer:** {actor.name}#{actor.discriminator} (`{actor.id}`)\n" + + if change.lower() == "enabled": + icon = Icons.defcon_enabled + color = Colours.soft_green + status_msg = "DEFCON enabled" + log_msg += f"**Days:** {self.days.days}\n\n" + elif change.lower() == "disabled": + icon = Icons.defcon_disabled + color = Colours.soft_red + status_msg = "DEFCON enabled" + elif change.lower() == "updated": + icon = Icons.defcon_updated + color = Colour.blurple() + status_msg = "DEFCON updated" + log_msg += f"**Days:** {self.days.days}\n\n" + + if e: + log_msg += ( + "**There was a problem updating the site** - This setting may be reverted when the bot restarts.\n\n" + f"```py\n{e}\n```" + ) + + await self.mod_log.send_log_message(icon, color, status_msg, log_msg) -def setup(bot: Bot): +def setup(bot: Bot) -> None: + """DEFCON cog load.""" bot.add_cog(Defcon(bot)) log.info("Cog loaded: Defcon") diff --git a/bot/cogs/deployment.py b/bot/cogs/deployment.py deleted file mode 100644 index e71e07c2f..000000000 --- a/bot/cogs/deployment.py +++ /dev/null @@ -1,90 +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): - """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): - """ - 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): - """ - 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): - """ - 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.add_cog(Deployment(bot)) - log.info("Cog loaded: Deployment") diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index 2f2cf8000..e5c51748f 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -1,20 +1,20 @@ import asyncio import functools import logging -import random import re import textwrap from collections import OrderedDict -from typing import Dict, List, Optional, Tuple +from typing import Any, Callable, Optional, Tuple import discord from bs4 import BeautifulSoup +from bs4.element import PageElement from discord.ext import commands from markdownify import MarkdownConverter from requests import ConnectionError from sphinx.ext import intersphinx -from bot.constants import ERROR_REPLIES, Keys, MODERATION_ROLES, URLs +from bot.constants import MODERATION_ROLES from bot.converters import ValidPythonIdentifier, ValidURL from bot.decorators import with_role from bot.pagination import LinePaginator @@ -28,24 +28,22 @@ UNWANTED_SIGNATURE_SYMBOLS = ('[source]', '¶') WHITESPACE_AFTER_NEWLINES_RE = re.compile(r"(?<=\n\n)(\s+)") -def async_cache(max_size=128, arg_offset=0): +def async_cache(max_size: int = 128, arg_offset: int = 0) -> Callable: """ LRU cache implementation for coroutines. - :param max_size: - Specifies the maximum size the cache should have. - Once it exceeds the maximum size, keys are deleted in FIFO order. - :param arg_offset: - The offset that should be applied to the coroutine's arguments - when creating the cache key. Defaults to `0`. - """ + Once the cache exceeds the maximum size, keys are deleted in FIFO order. + An offset may be optionally provided to be applied to the coroutine's arguments when creating the cache key. + """ # Assign the cache to the function itself so we can clear it from outside. async_cache.cache = OrderedDict() - def decorator(function): + def decorator(function: Callable) -> Callable: + """Define the async_cache decorator.""" @functools.wraps(function) - async def wrapper(*args): + async def wrapper(*args) -> Any: + """Decorator wrapper for the caching logic.""" key = ':'.join(args[arg_offset:]) value = async_cache.cache.get(key) @@ -60,27 +58,25 @@ def async_cache(max_size=128, arg_offset=0): class DocMarkdownConverter(MarkdownConverter): - def convert_code(self, el, text): - """Undo `markdownify`s underscore escaping.""" + """Subclass markdownify's MarkdownCoverter to provide custom conversion methods.""" + def convert_code(self, el: PageElement, text: str) -> str: + """Undo `markdownify`s underscore escaping.""" return f"`{text}`".replace('\\', '') - def convert_pre(self, el, text): + def convert_pre(self, el: PageElement, text: str) -> str: """Wrap any codeblocks in `py` for syntax highlighting.""" - code = ''.join(el.strings) return f"```py\n{code}```" -def markdownify(html): +def markdownify(html: str) -> DocMarkdownConverter: + """Create a DocMarkdownConverter object from the input html.""" return DocMarkdownConverter(bullets='•').convert(html) class DummyObject(object): - """ - A dummy object which supports assigning anything, - which the builtin `object()` does not support normally. - """ + """A dummy object which supports assigning anything, which the builtin `object()` does not support normally.""" class SphinxConfiguration: @@ -95,14 +91,15 @@ class InventoryURL(commands.Converter): """ Represents an Intersphinx inventory URL. - This converter checks whether intersphinx - accepts the given inventory URL, and raises + This converter checks whether intersphinx accepts the given inventory URL, and raises `BadArgument` if that is not the case. + Otherwise, it simply passes through the given URL. """ @staticmethod - async def convert(ctx, url: str): + async def convert(ctx: commands.Context, url: str) -> str: + """Convert url to Intersphinx inventory URL.""" try: intersphinx.fetch_inventory(SphinxConfiguration(), '', url) except AttributeError: @@ -121,32 +118,34 @@ class InventoryURL(commands.Converter): return url -class Doc: - def __init__(self, bot): +class Doc(commands.Cog): + """A set of commands for querying & displaying documentation.""" + + def __init__(self, bot: commands.Bot): self.base_urls = {} self.bot = bot self.inventories = {} - self.headers = {"X-API-KEY": Keys.site_api} - async def on_ready(self): + @commands.Cog.listener() + async def on_ready(self) -> None: + """Refresh documentation inventory.""" await self.refresh_inventory() async def update_single( self, package_name: str, base_url: str, inventory_url: str, config: SphinxConfiguration - ): + ) -> None: """ Rebuild the inventory for a single package. - :param package_name: The package name to use, appears in the log. - :param base_url: The root documentation URL for the specified package. - Used to build absolute paths that link to specific symbols. - :param inventory_url: The absolute URL to the intersphinx inventory. - Fetched by running `intersphinx.fetch_inventory` in an - executor on the bot's event loop. - :param config: A `SphinxConfiguration` instance to mock the regular sphinx - project layout. Required for use with intersphinx. + Where: + * `package_name` is the package name to use, appears in the log + * `base_url` is the root documentation URL for the specified package, used to build + absolute paths that link to specific symbols + * `inventory_url` is the absolute URL to the intersphinx inventory, fetched by running + `intersphinx.fetch_inventory` in an executor on the bot's event loop + * `config` is a `SphinxConfiguration` instance to mock the regular sphinx + project layout, required for use with intersphinx """ - self.base_urls[package_name] = base_url fetch_func = functools.partial(intersphinx.fetch_inventory, config, '', inventory_url) @@ -160,7 +159,8 @@ class Doc: log.trace(f"Fetched inventory for {package_name}.") - async def refresh_inventory(self): + async def refresh_inventory(self) -> None: + """Refresh internal documentation inventory.""" log.debug("Refreshing documentation inventory...") # Clear the old base URLS and inventories to ensure @@ -179,7 +179,7 @@ class Doc: coros = [ self.update_single( package["package"], package["base_url"], package["inventory_url"], config - ) for package in await self.get_all_packages() + ) for package in await self.bot.api_client.get('bot/documentation-links') ] await asyncio.gather(*coros) @@ -187,16 +187,13 @@ class Doc: """ Given a Python symbol, return its signature and description. - :param symbol: The symbol for which HTML data should be returned. - :return: - A tuple in the form (str, str), or `None`. - The first tuple element is the signature of the given - symbol as a markup-free string, and the second tuple - element is the description of the given symbol with HTML - markup included. If the given symbol could not be found, - returns `None`. - """ + Returns a tuple in the form (str, str), or `None`. + The first tuple element is the signature of the given symbol as a markup-free string, and + the second tuple element is the description of the given symbol with HTML markup included. + + If the given symbol could not be found, returns `None`. + """ url = self.inventories.get(symbol) if url is None: return None @@ -224,16 +221,10 @@ class Doc: @async_cache(arg_offset=1) async def get_symbol_embed(self, symbol: str) -> Optional[discord.Embed]: """ - Using `get_symbol_html`, attempt to scrape and - fetch the data for the given `symbol`, and build - a formatted embed out of its contents. - - :param symbol: The symbol for which the embed should be returned - :return: - If the symbol is known, an Embed with documentation about it. - Otherwise, `None`. - """ + Attempt to scrape and fetch the data for the given `symbol`, and build an embed from its contents. + If the symbol is known, an Embed with documentation about it is returned. + """ scraped_html = await self.get_symbol_html(symbol) if scraped_html is None: return None @@ -267,110 +258,17 @@ class Doc: description=f"```py\n{signature}```{description}" ) - async def get_all_packages(self) -> List[Dict[str, str]]: - """ - Performs HTTP GET to get all packages from the website. - - :return: - A list of packages, in the following format: - [ - { - "package": "example-package", - "base_url": "https://example.readthedocs.io", - "inventory_url": "https://example.readthedocs.io/objects.inv" - }, - ... - ] - `package` specifies the package name, for example 'aiohttp'. - `base_url` specifies the documentation root URL, used to build absolute links. - `inventory_url` specifies the location of the Intersphinx inventory. - """ - - async with self.bot.http_session.get(URLs.site_docs_api, headers=self.headers) as resp: - return await resp.json() - - async def get_package(self, package_name: str) -> Optional[Dict[str, str]]: - """ - Performs HTTP GET to get the specified package from the documentation database. - - :param package_name: The package name for which information should be returned. - :return: - Either a dictionary with information in the following format: - { - "package": "example-package", - "base_url": "https://example.readthedocs.io", - "inventory_url": "https://example.readthedocs.io/objects.inv" - } - or `None` if the site didn't returned no results for the given name. - """ - - params = {"package": package_name} - - async with self.bot.http_session.get(URLs.site_docs_api, - headers=self.headers, - params=params) as resp: - package_data = await resp.json() - if not package_data: - return None - return package_data[0] - - async def set_package(self, name: str, base_url: str, inventory_url: str) -> Dict[str, bool]: - """ - Performs HTTP POST to add a new package to the website's documentation database. - - :param name: The name of the package, for example `aiohttp`. - :param base_url: The documentation root URL, used to build absolute links. - :param inventory_url: The absolute URl to the intersphinx inventory of the package. - - :return: The JSON response of the server, which is always: - { - "success": True - } - """ - - package_json = { - 'package': name, - 'base_url': base_url, - 'inventory_url': inventory_url - } - - async with self.bot.http_session.post(URLs.site_docs_api, - headers=self.headers, - json=package_json) as resp: - return await resp.json() - - async def delete_package(self, name: str) -> bool: - """ - Performs HTTP DELETE to delete the specified package from the documentation database. - - :param name: The package to delete. - - :return: `True` if successful, `False` if the package is unknown. - """ - - package_json = {'package': name} - - async with self.bot.http_session.delete(URLs.site_docs_api, - headers=self.headers, - json=package_json) as resp: - changes = await resp.json() - return changes["deleted"] == 1 # Did the package delete successfully? - @commands.group(name='docs', aliases=('doc', 'd'), invoke_without_command=True) - async def docs_group(self, ctx, symbol: commands.clean_content = None): + async def docs_group(self, ctx: commands.Context, symbol: commands.clean_content = None) -> None: """Lookup documentation for Python symbols.""" - await ctx.invoke(self.get_command) @docs_group.command(name='get', aliases=('g',)) - async def get_command(self, ctx, symbol: commands.clean_content = None): + async def get_command(self, ctx: commands.Context, symbol: commands.clean_content = None) -> None: """ Return a documentation embed for a given symbol. - If no symbol is given, return a list of all available inventories. - :param ctx: Discord message context - :param symbol: The symbol for which documentation should be returned, - or nothing to get a list of all inventories + If no symbol is given, return a list of all available inventories. Examples: !docs @@ -378,7 +276,6 @@ class Doc: !docs aiohttp.ClientSession !docs get aiohttp.ClientSession """ - if symbol is None: inventory_embed = discord.Embed( title=f"All inventories (`{len(self.base_urls)}` total)", @@ -386,7 +283,12 @@ class Doc: ) lines = sorted(f"• [`{name}`]({url})" for name, url in self.base_urls.items()) - await LinePaginator.paginate(lines, ctx, inventory_embed, max_size=400, empty=False) + if self.base_urls: + await LinePaginator.paginate(lines, ctx, inventory_embed, max_size=400, empty=False) + + else: + inventory_embed.description = "Hmmm, seems like there's nothing here yet." + await ctx.send(embed=inventory_embed) else: # Fetching documentation for a symbol (at least for the first time, since @@ -407,18 +309,13 @@ class Doc: @docs_group.command(name='set', aliases=('s',)) @with_role(*MODERATION_ROLES) async def set_command( - self, ctx, package_name: ValidPythonIdentifier, + self, ctx: commands.Context, package_name: ValidPythonIdentifier, base_url: ValidURL, inventory_url: InventoryURL - ): + ) -> None: """ Adds a new documentation metadata object to the site's database. - The database will update the object, should an existing item - with the specified `package_name` already exist. - :param ctx: Discord message context - :param package_name: The package name, for example `aiohttp`. - :param base_url: The package documentation's root URL, used to build absolute links. - :param inventory_url: The intersphinx inventory URL. + The database will update the object, should an existing item with the specified `package_name` already exist. Example: !docs set \ @@ -426,8 +323,13 @@ class Doc: https://discordpy.readthedocs.io/en/rewrite/ \ https://discordpy.readthedocs.io/en/rewrite/objects.inv """ + body = { + 'package': package_name, + 'base_url': base_url, + 'inventory_url': inventory_url + } + await self.bot.api_client.post('bot/documentation-links', json=body) - await self.set_package(package_name, base_url, inventory_url) log.info( f"User @{ctx.author.name}#{ctx.author.discriminator} ({ctx.author.id}) " "added a new documentation package:\n" @@ -444,54 +346,23 @@ class Doc: @docs_group.command(name='delete', aliases=('remove', 'rm', 'd')) @with_role(*MODERATION_ROLES) - async def delete_command(self, ctx, package_name: ValidPythonIdentifier): + async def delete_command(self, ctx: commands.Context, package_name: ValidPythonIdentifier) -> None: """ Removes the specified package from the database. - :param ctx: Discord message context - :param package_name: The package name, for example `aiohttp`. - Examples: !docs delete aiohttp """ + await self.bot.api_client.delete(f'bot/documentation-links/{package_name}') - success = await self.delete_package(package_name) - if success: - - async with ctx.typing(): - # Rebuild the inventory to ensure that everything - # that was from this package is properly deleted. - await self.refresh_inventory() - await ctx.send(f"Successfully deleted `{package_name}` and refreshed inventory.") - - else: - await ctx.send( - f"Can't find any package named `{package_name}` in the database. " - "View all known packages by using `docs.get()`." - ) - - @get_command.error - @delete_command.error - @set_command.error - async def general_command_error(self, ctx, error: commands.CommandError): - """ - Handle the `BadArgument` error caused by - the commands when argument validation fails. - - :param ctx: Discord message context of the message creating the error - :param error: The error raised, usually `BadArgument` - """ - - if isinstance(error, commands.BadArgument): - embed = discord.Embed( - title=random.choice(ERROR_REPLIES), - description=f"Error: {error}", - colour=discord.Colour.red() - ) - await ctx.send(embed=embed) - else: - log.exception(f"Unhandled error: {error}") + async with ctx.typing(): + # Rebuild the inventory to ensure that everything + # that was from this package is properly deleted. + await self.refresh_inventory() + await ctx.send(f"Successfully deleted `{package_name}` and refreshed inventory.") -def setup(bot): +def setup(bot: commands.Bot) -> None: + """Doc cog load.""" bot.add_cog(Doc(bot)) + log.info("Cog loaded: Doc") diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py new file mode 100644 index 000000000..49411814c --- /dev/null +++ b/bot/cogs/error_handler.py @@ -0,0 +1,148 @@ +import contextlib +import logging + +from discord.ext.commands import ( + BadArgument, + BotMissingPermissions, + CheckFailure, + CommandError, + CommandInvokeError, + CommandNotFound, + CommandOnCooldown, + DisabledCommand, + MissingPermissions, + NoPrivateMessage, + UserInputError, +) +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(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 generic command error handling. + + Error handling is deferred to any local error handler, if present. + + Error handling emits a single error response, prioritized as follows: + 1. If the name fails to match a command but matches a tag, the tag is invoked + 2. Send a BadArgument error message to the invoking context & invoke the command's help + 3. Send a UserInputError error message to the invoking context & invoke the command's help + 4. Send a NoPrivateMessage error message to the invoking context + 5. Send a BotMissingPermissions error message to the invoking context + 6. Log a MissingPermissions error, no message is sent + 7. Send a InChannelCheckFailure error message to the invoking context + 8. Log CheckFailure, CommandOnCooldown, and DisabledCommand errors, no message is sent + 9. For CommandInvokeErrors, response is based on the type of error: + * 404: Error message is sent to the invoking context + * 400: Log the resopnse JSON, no message is sent + * 500 <= status <= 600: Error message is sent to the invoking context + 10. Otherwise, handling is deferred to `handle_unexpected_error` + """ + command = ctx.command + parent = None + + 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: + help_command = (self.bot.get_command("help"), command.name) + else: + help_command = (self.bot.get_command("help"),) + + 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"): + 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): + await ctx.invoke(tags_get_command, tag_name=ctx.invoked_with) + return + 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.") + 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): + status = e.original.response.status + + if status == 404: + await ctx.send("There does not seem to be anything matching your query.") + elif status == 400: + content = await e.original.response.json() + 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 <= 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(f"Got an unexpected status code from the API (`{status}`).") + log.warning(f"Unexpected API response for command {command}: {status}") + else: + await self.handle_unexpected_error(ctx, e.original) + else: + 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: + """Error handler cog load.""" + bot.add_cog(ErrorHandler(bot)) + log.info("Cog loaded: Events") diff --git a/bot/cogs/eval.py b/bot/cogs/eval.py index 8e97a35a2..9ce854f2c 100644 --- a/bot/cogs/eval.py +++ b/bot/cogs/eval.py @@ -6,9 +6,10 @@ import re import textwrap import traceback from io import StringIO +from typing import Any, Optional, Tuple import discord -from discord.ext.commands import Bot, group +from discord.ext.commands import Bot, Cog, Context, group from bot.constants import Roles from bot.decorators import with_role @@ -17,11 +18,8 @@ from bot.interpreter import Interpreter log = logging.getLogger(__name__) -class CodeEval: - """ - Owner and admin feature that evaluates code - and returns the result to the channel. - """ +class CodeEval(Cog): + """Owner and admin feature that evaluates code and returns the result to the channel.""" def __init__(self, bot: Bot): self.bot = bot @@ -31,7 +29,8 @@ class CodeEval: self.interpreter = Interpreter(bot) - def _format(self, inp, out): # (str, Any) -> (str, discord.Embed) + def _format(self, inp: str, out: Any) -> Tuple[str, Optional[discord.Embed]]: + """Format the eval output into a string & attempt to format it into an Embed.""" self._ = out res = "" @@ -124,7 +123,8 @@ class CodeEval: return res # Return (text, embed) - async def _eval(self, ctx, code): # (discord.Context, str) -> None + async def _eval(self, ctx: Context, code: str) -> Optional[discord.Message]: + """Eval the input code string & send an embed to the invoking context.""" self.ln += 1 if code.startswith("exit"): @@ -174,16 +174,15 @@ async def func(): # (None,) -> Any @group(name='internal', aliases=('int',)) @with_role(Roles.owner, Roles.admin) - async def internal_group(self, ctx): + async def internal_group(self, ctx: Context) -> None: """Internal commands. Top secret!""" - if not ctx.invoked_subcommand: await ctx.invoke(self.bot.get_command("help"), "internal") @internal_group.command(name='eval', aliases=('e',)) @with_role(Roles.admin, Roles.owner) - async def eval(self, ctx, *, code: str): - """ Run eval in a REPL-like format. """ + async def eval(self, ctx: Context, *, code: str) -> None: + """Run eval in a REPL-like format.""" code = code.strip("`") if re.match('py(thon)?\n', code): code = "\n".join(code.split("\n")[1:]) @@ -197,6 +196,7 @@ async def func(): # (None,) -> Any await self._eval(ctx, code) -def setup(bot): +def setup(bot: Bot) -> None: + """Code eval cog load.""" bot.add_cog(CodeEval(bot)) log.info("Cog loaded: Eval") diff --git a/bot/cogs/events.py b/bot/cogs/events.py deleted file mode 100644 index 2819b7dcc..000000000 --- a/bot/cogs/events.py +++ /dev/null @@ -1,311 +0,0 @@ -import logging -from functools import partial -from typing import List - -from discord import Colour, Embed, Member, Object -from discord.ext.commands import ( - BadArgument, Bot, BotMissingPermissions, - CommandError, CommandInvokeError, CommandNotFound, - Context, NoPrivateMessage, UserInputError -) - -from bot.constants import ( - Channels, Colours, DEBUG_MODE, - Guild, Icons, Keys, - Roles, URLs -) -from bot.utils import chunks - -log = logging.getLogger(__name__) - -RESTORE_ROLES = (str(Roles.muted), str(Roles.announcements)) - - -class Events: - """No commands, just event handlers.""" - - def __init__(self, bot: Bot): - self.bot = bot - self.headers = {"X-API-KEY": Keys.site_api} - - @property - def send_log(self) -> partial: - cog = self.bot.get_cog("ModLog") - return partial(cog.send_log_message, channel_id=Channels.userlog) - - async def send_updated_users(self, *users, replace_all=False): - users = list(filter(lambda user: str(Roles.verified) in user["roles"], users)) - - for chunk in chunks(users, 1000): - response = None - - try: - if replace_all: - response = await self.bot.http_session.post( - url=URLs.site_user_api, - json=chunk, - headers={"X-API-Key": Keys.site_api} - ) - else: - response = await self.bot.http_session.put( - url=URLs.site_user_api, - json=chunk, - headers={"X-API-Key": Keys.site_api} - ) - - await response.json() # We do this to ensure we got a proper response from the site - except Exception: - if not response: - log.exception(f"Failed to send {len(chunk)} users") - else: - text = await response.text() - log.exception(f"Failed to send {len(chunk)} users", extra={"body": text}) - break # Stop right now, thank you very much - - result = {} - - if replace_all: - response = None - - try: - response = await self.bot.http_session.post( - url=URLs.site_user_complete_api, - headers={"X-API-Key": Keys.site_api} - ) - - result = await response.json() - except Exception: - if not response: - log.exception(f"Failed to send {len(chunk)} users") - else: - text = await response.text() - log.exception(f"Failed to send {len(chunk)} users", extra={"body": text}) - - return result - - async def send_delete_users(self, *users): - try: - response = await self.bot.http_session.delete( - url=URLs.site_user_api, - json=list(users), - headers={"X-API-Key": Keys.site_api} - ) - - return await response.json() - except Exception: - log.exception(f"Failed to delete {len(users)} users") - return {} - - async def get_user(self, user_id): - response = await self.bot.http_session.get( - url=URLs.site_user_api, - params={"user_id": user_id}, - headers={"X-API-Key": Keys.site_api} - ) - - resp = await response.json() - return resp["data"] - - async def has_active_mute(self, user_id: str) -> bool: - """ - Check whether a user has any active mute infractions - """ - - response = await self.bot.http_session.get( - URLs.site_infractions_user.format( - user_id=user_id - ), - params={"hidden": "True"}, - headers=self.headers - ) - infraction_list = await response.json() - - # Check for active mute infractions - if not infraction_list: - # Short circuit - return False - - return any( - infraction["active"] for infraction in infraction_list if infraction["type"].lower() == "mute" - ) - - async def on_command_error(self, ctx: Context, e: CommandError): - command = ctx.command - parent = None - - if command is not None: - parent = command.parent - - if parent and command: - help_command = (self.bot.get_command("help"), parent.name, command.name) - elif command: - help_command = (self.bot.get_command("help"), command.name) - else: - help_command = (self.bot.get_command("help"),) - - if hasattr(command, "on_error"): - log.debug(f"Command {command} has a local error handler, ignoring.") - return - - 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 - - # Return to not raise the exception - 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.invoke(*help_command) - 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}**" - ) - elif isinstance(e, CommandInvokeError): - await ctx.send( - f"Sorry, an unexpected error occurred. Please let us know!\n\n```{e}```" - ) - raise e.original - raise e - - async def on_ready(self): - users = [] - - for member in self.bot.get_guild(Guild.id).members: # type: Member - roles: List[int] = [str(r.id) for r in member.roles] - - users.append({ - "avatar": member.avatar_url_as(format="png"), - "user_id": str(member.id), - "roles": roles, - "username": member.name, - "discriminator": member.discriminator - }) - - if users: - log.info(f"{len(users)} user roles to be updated") - - done = await self.send_updated_users(*users, replace_all=True) - - if any(done.values()): - embed = Embed( - title="Users updated" - ) - - for key, value in done.items(): - if value: - if key == "deleted_oauth": - key = "Deleted (OAuth)" - elif key == "deleted_jam_profiles": - key = "Deleted (Jammer Profiles)" - elif key == "deleted_responses": - key = "Deleted (Jam Form Responses)" - elif key == "jam_bans": - key = "Ex-Jammer Bans" - else: - key = key.title() - - embed.add_field( - name=key, value=str(value) - ) - - if not DEBUG_MODE: - await self.bot.get_channel(Channels.devlog).send( - embed=embed - ) - - async def on_member_update(self, before: Member, after: Member): - if ( - before.roles == after.roles - and before.name == after.name - and before.discriminator == after.discriminator - and before.avatar == after.avatar): - return - - before_role_names: List[str] = [role.name for role in before.roles] - after_role_names: List[str] = [role.name for role in after.roles] - role_ids: List[str] = [str(r.id) for r in after.roles] - - log.debug(f"{before.display_name} roles changing from {before_role_names} to {after_role_names}") - - changes = await self.send_updated_users({ - "avatar": after.avatar_url_as(format="png"), - "user_id": str(after.id), - "roles": role_ids, - "username": after.name, - "discriminator": after.discriminator - }) - - log.debug(f"User {after.id} updated; changes: {changes}") - - async def on_member_join(self, member: Member): - role_ids: List[str] = [str(r.id) for r in member.roles] - new_roles = [] - - try: - user_objs = await self.get_user(str(member.id)) - except Exception as e: - log.exception("Failed to persist roles") - - await self.send_log( - Icons.crown_red, Colour(Colours.soft_red), "Failed to persist roles", - f"```py\n{e}\n```", - member.avatar_url_as(static_format="png") - ) - else: - if user_objs: - old_roles = user_objs[0].get("roles", []) - - for role in RESTORE_ROLES: - if role in old_roles: - # Check for mute roles that were not able to be removed and skip if present - if role == str(Roles.muted) and not await self.has_active_mute(str(member.id)): - log.debug( - f"User {member.id} has no active mute infraction, " - "their leftover muted role will not be persisted" - ) - continue - - new_roles.append(Object(int(role))) - - for role in new_roles: - if str(role) not in role_ids: - role_ids.append(str(role.id)) - - changes = await self.send_updated_users({ - "avatar": member.avatar_url_as(format="png"), - "user_id": str(member.id), - "roles": role_ids, - "username": member.name, - "discriminator": member.discriminator - }) - - log.debug(f"User {member.id} joined; changes: {changes}") - - if new_roles: - await member.add_roles( - *new_roles, - reason="Roles restored" - ) - - await self.send_log( - Icons.crown_blurple, Colour.blurple(), "Roles restored", - f"Restored {len(new_roles)} roles", - member.avatar_url_as(static_format="png") - ) - - async def on_member_remove(self, member: Member): - changes = await self.send_delete_users({ - "user_id": str(member.id) - }) - - log.debug(f"User {member.id} left; changes: {changes}") - - -def setup(bot): - bot.add_cog(Events(bot)) - log.info("Cog loaded: Events") diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 418297fc4..9cd1b7203 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,11 +29,8 @@ URL_RE = r"(https?://[^\s]+)" ZALGO_RE = r"[\u0300-\u036F\u0489]" -class Filtering: - """ - Filtering out invites, blacklisting domains, - and warning us of certain regular expressions - """ +class Filtering(Cog): + """Filtering out invites, blacklisting domains, and warning us of certain regular expressions.""" def __init__(self, bot: Bot): self.bot = bot @@ -59,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,26 +91,29 @@ class Filtering: @property def mod_log(self) -> ModLog: + """Get currently loaded ModLog cog instance.""" return self.bot.get_cog("ModLog") - async def on_message(self, msg: Message): + @Cog.listener() + async def on_message(self, msg: Message) -> None: + """Invoke message filter for new messages.""" await self._filter_message(msg) - async def on_message_edit(self, before: Message, after: Message): + @Cog.listener() + async def on_message_edit(self, before: Message, after: Message) -> None: + """ + Invoke message filter for message edits. + + If there have been multiple edits, calculate the time delta from the previous edit. + """ 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): - """ - Whenever a message is sent or edited, - run it through our filters to see if it - violates any of our rules, and then respond - accordingly. - """ - + async def _filter_message(self, msg: Message, delta: Optional[int] = None) -> None: + """Filter the input message to see if it violates any of our rules, and then respond accordingly.""" # Should we filter this message? role_whitelisted = False @@ -142,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"]: @@ -224,14 +224,10 @@ class Filtering: @staticmethod async def _has_watchlist_words(text: str) -> bool: """ - Returns True if the text contains - one of the regular expressions from the - word_watchlist in our filter config. + Returns True if the text contains one of the regular expressions from the word_watchlist in our filter config. - Only matches words with boundaries before - and after the expression. + Only matches words with boundaries before and after the expression. """ - for expression in Filter.word_watchlist: if re.search(fr"\b{expression}\b", text, re.IGNORECASE): return True @@ -241,14 +237,10 @@ class Filtering: @staticmethod async def _has_watchlist_tokens(text: str) -> bool: """ - Returns True if the text contains - one of the regular expressions from the - token_watchlist in our filter config. + Returns True if the text contains one of the regular expressions from the token_watchlist in our filter config. - This will match the expression even if it - does not have boundaries before and after + This will match the expression even if it does not have boundaries before and after. """ - for expression in Filter.token_watchlist: if re.search(fr"{expression}", text, re.IGNORECASE): @@ -260,11 +252,7 @@ class Filtering: @staticmethod async def _has_urls(text: str) -> bool: - """ - Returns True if the text contains one of - the blacklisted URLs from the config file. - """ - + """Returns True if the text contains one of the blacklisted URLs from the config file.""" if not re.search(URL_RE, text, re.IGNORECASE): return False @@ -283,7 +271,6 @@ class Filtering: Zalgo range is \u0300 – \u036F and \u0489. """ - return bool(re.search(ZALGO_RE, text)) async def _has_invites(self, text: str) -> Union[dict, bool]: @@ -295,7 +282,6 @@ class Filtering: Attempts to catch some of common ways to try to cheat the system. """ - # Remove backslashes to prevent escape character aroundfuckery like # discord\.gg/gdudes-pony-farm text = text.replace("\\", "") @@ -336,30 +322,27 @@ class Filtering: return invite_data if invite_data else False @staticmethod - async def _has_rich_embed(msg: Message): - """ - Returns True if any of the embeds in the message are of type 'rich', but are not twitter - embeds. Returns False otherwise. - """ + async def _has_rich_embed(msg: Message) -> bool: + """Returns True if any of the embeds in the message are of type 'rich', but are not twitter embeds.""" if msg.embeds: for embed in msg.embeds: if embed.type == "rich" and (not embed.url or "twitter.com" not in embed.url): return True return False - async def notify_member(self, filtered_member: Member, reason: str, channel: TextChannel): + async def notify_member(self, filtered_member: Member, reason: str, channel: TextChannel) -> None: """ - Notify filtered_member about a moderation action with the reason str + Notify filtered_member about a moderation action with the reason str. First attempts to DM the user, fall back to in-channel notification if user has DMs disabled """ - try: await filtered_member.send(reason) except discord.errors.Forbidden: await channel.send(f"{filtered_member.mention} {reason}") -def setup(bot: Bot): +def setup(bot: Bot) -> None: + """Filtering cog load.""" bot.add_cog(Filtering(bot)) log.info("Cog loaded: Filtering") diff --git a/bot/cogs/free.py b/bot/cogs/free.py index fd6009bb8..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 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,18 +15,16 @@ 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 @command(name="free", aliases=('f',)) @redirect_output(destination_channel=Channels.bot, bypass_roles=STAFF_ROLES) - async def free(self, ctx: Context, user: Member = None, seek: int = 2): + async def free(self, ctx: Context, user: Member = None, seek: int = 2) -> None: """ Lists free help channels by likeliness of availability. - :param user: accepts user mention, ID, etc. - :param seek: How far back to check the last active message. seek is used only when this command is invoked in a help channel. You cannot override seek without mentioning a user first. @@ -101,6 +99,7 @@ class Free: await ctx.send(embed=embed) -def setup(bot): +def setup(bot: Bot) -> None: + """Free cog load.""" bot.add_cog(Free()) log.info("Cog loaded: Free") diff --git a/bot/cogs/fun.py b/bot/cogs/fun.py deleted file mode 100644 index 57fa7cb1c..000000000 --- a/bot/cogs/fun.py +++ /dev/null @@ -1,53 +0,0 @@ -import logging - -from discord import Message -from discord.ext.commands import Bot - -from bot.constants import Channels - -RESPONSES = { - "_pokes {us}_": "_Pokes {them}_", - "_eats {us}_": "_Tastes slimy and snake-like_", - "_pets {us}_": "_Purrs_" -} - -log = logging.getLogger(__name__) - - -class Fun: - """ - Fun, entirely useless stuff - """ - - def __init__(self, bot: Bot): - self.bot = bot - - async def on_ready(self): - keys = list(RESPONSES.keys()) - - for key in keys: - changed_key = key.replace("{us}", self.bot.user.mention) - - if key != changed_key: - RESPONSES[changed_key] = RESPONSES[key] - del RESPONSES[key] - - async def on_message(self, message: Message): - if message.channel.id != Channels.bot: - return - - content = message.content - - if content and content[0] == "*" and content[-1] == "*": - content = f"_{content[1:-1]}_" - - response = RESPONSES.get(content) - - if response: - log.debug(f"{message.author} said '{message.clean_content}'. Responding with '{response}'.") - await message.channel.send(response.format(them=message.author.mention)) - - -def setup(bot): - bot.add_cog(Fun(bot)) - log.info("Cog loaded: Fun") diff --git a/bot/cogs/help.py b/bot/cogs/help.py index 20ed08f07..4971cd0bb 100644 --- a/bot/cogs/help.py +++ b/bot/cogs/help.py @@ -3,10 +3,11 @@ import inspect import itertools from collections import namedtuple from contextlib import suppress +from typing import Union -from discord import Colour, Embed, HTTPException +from discord import Colour, Embed, HTTPException, Message, Reaction, User from discord.ext import commands -from discord.ext.commands import CheckFailure +from discord.ext.commands import Bot, CheckFailure, Cog as DiscordCog, Command, Context from fuzzywuzzy import fuzz, process from bot import constants @@ -35,15 +36,11 @@ class HelpQueryNotFound(ValueError): Contains the custom attribute of ``possible_matches``. - Attributes - ---------- - possible_matches: dict - Any commands that were close to matching the Query. - The possible matched command names are the keys. - The likeness match scores are the values. + Instances of this object contain a dictionary of any command(s) that were close to matching the + query, where keys are the possible matched command names and values are the likeness match scores. """ - def __init__(self, arg, possible_matches=None): + def __init__(self, arg: str, possible_matches: dict = None): super().__init__(arg) self.possible_matches = possible_matches @@ -52,48 +49,30 @@ class HelpSession: """ An interactive session for bot and command help output. - Attributes - ---------- - title: str - The title of the help message. - query: Union[:class:`discord.ext.commands.Bot`, - :class:`discord.ext.commands.Command] - description: str - The description of the query. - pages: list[str] - A list of the help content split into manageable pages. - message: :class:`discord.Message` - The message object that's showing the help contents. - destination: :class:`discord.abc.Messageable` - Where the help message is to be sent to. + Expected attributes include: + * title: str + The title of the help message. + * query: Union[discord.ext.commands.Bot, discord.ext.commands.Command] + * description: str + The description of the query. + * pages: list[str] + A list of the help content split into manageable pages. + * message: `discord.Message` + The message object that's showing the help contents. + * destination: `discord.abc.Messageable` + Where the help message is to be sent to. """ - def __init__(self, ctx, *command, cleanup=False, only_can_run=True, show_hidden=False, max_lines=15): - """ - Creates an instance of the HelpSession class. - - Parameters - ---------- - ctx: :class:`discord.Context` - The context of the invoked help command. - *command: str - A variable argument of the command being queried. - cleanup: Optional[bool] - Set to ``True`` to have the message deleted on timeout. - If ``False``, it will clear all reactions on timeout. - Defaults to ``False``. - only_can_run: Optional[bool] - Set to ``True`` to hide commands the user can't run. - Defaults to ``False``. - show_hidden: Optional[bool] - Set to ``True`` to include hidden commands. - Defaults to ``False``. - max_lines: Optional[int] - Sets the max number of lines the paginator will add to a - single page. - Defaults to 20. - """ - + def __init__( + self, + ctx: Context, + *command, + cleanup: bool = False, + only_can_run: bool = True, + show_hidden: bool = False, + max_lines: int = 15 + ): + """Creates an instance of the HelpSession class.""" self._ctx = ctx self._bot = ctx.bot self.title = "Command Help" @@ -107,7 +86,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 @@ -122,20 +101,8 @@ class HelpSession: self._timeout_task = None self.reset_timeout() - def _get_query(self, query): - """ - Attempts to match the provided query with a valid command or cog. - - Parameters - ---------- - query: str - The joined string representing the session query. - - Returns - ------- - Union[:class:`discord.ext.commands.Command`, :class:`Cog`] - """ - + def _get_query(self, query: str) -> Union[Command, Cog]: + """Attempts to match the provided query with a valid command or cog.""" command = self._bot.get_command(query) if command: return command @@ -150,48 +117,26 @@ class HelpSession: self._handle_not_found(query) - def _handle_not_found(self, query): + def _handle_not_found(self, query: str) -> None: """ Handles when a query does not match a valid command or cog. - Will pass on possible close matches along with the - ``HelpQueryNotFound`` exception. - - Parameters - ---------- - query: str - The full query that was requested. - - Raises - ------ - HelpQueryNotFound + Will pass on possible close matches along with the `HelpQueryNotFound` exception. """ - - # combine command and cog names + # Combine command and cog names choices = list(self._bot.all_commands) + list(self._bot.cogs) result = process.extractBests(query, choices, scorer=fuzz.ratio, score_cutoff=90) raise HelpQueryNotFound(f'Query "{query}" not found.', dict(result)) - async def timeout(self, seconds=30): - """ - Waits for a set number of seconds, then stops the help session. - - Parameters - ---------- - seconds: int - Number of seconds to wait. - """ - + async def timeout(self, seconds: int = 30) -> None: + """Waits for a set number of seconds, then stops the help session.""" await asyncio.sleep(seconds) await self.stop() - def reset_timeout(self): - """ - Cancels the original timeout task and sets it again from the start. - """ - + def reset_timeout(self) -> None: + """Cancels the original timeout task and sets it again from the start.""" # cancel original if it exists if self._timeout_task: if not self._timeout_task.cancelled(): @@ -200,18 +145,8 @@ class HelpSession: # recreate the timeout task self._timeout_task = self._bot.loop.create_task(self.timeout()) - async def on_reaction_add(self, reaction, user): - """ - Event handler for when reactions are added on the help message. - - Parameters - ---------- - reaction: :class:`discord.Reaction` - The reaction that was added. - user: :class:`discord.User` - The user who added the reaction. - """ - + async def on_reaction_add(self, reaction: Reaction, user: User) -> None: + """Event handler for when reactions are added on the help message.""" # ensure it was the relevant session message if reaction.message.id != self.message.id: return @@ -237,24 +172,13 @@ class HelpSession: with suppress(HTTPException): await self.message.remove_reaction(reaction, user) - async def on_message_delete(self, message): - """ - Closes the help session when the help message is deleted. - - Parameters - ---------- - message: :class:`discord.Message` - The message that was deleted. - """ - + async def on_message_delete(self, message: Message) -> None: + """Closes the help session when the help message is deleted.""" if message.id == self.message.id: await self.stop() - async def prepare(self): - """ - Sets up the help session pages, events, message and reactions. - """ - + async def prepare(self) -> None: + """Sets up the help session pages, events, message and reactions.""" # create paginated content await self.build_pages() @@ -266,12 +190,8 @@ class HelpSession: await self.update_page() self.add_reactions() - def add_reactions(self): - """ - Adds the relevant reactions to the help message based on if - pagination is required. - """ - + def add_reactions(self) -> None: + """Adds the relevant reactions to the help message based on if pagination is required.""" # if paginating if len(self._pages) > 1: for reaction in REACTIONS: @@ -281,44 +201,22 @@ class HelpSession: else: self._bot.loop.create_task(self.message.add_reaction(DELETE_EMOJI)) - def _category_key(self, cmd): + def _category_key(self, cmd: Command) -> str: """ - Returns a cog name of a given command. Used as a key for - ``sorted`` and ``groupby``. - - A zero width space is used as a prefix for results with no cogs - to force them last in ordering. + Returns a cog name of a given command for use as a key for `sorted` and `groupby`. - Parameters - ---------- - cmd: :class:`discord.ext.commands.Command` - The command object being checked. - - Returns - ------- - str + A zero width space is used as a prefix for results with no cogs to force them last in ordering. """ - cog = cmd.cog_name return f'**{cog}**' if cog else f'**\u200bNo Category:**' - def _get_command_params(self, cmd): + def _get_command_params(self, cmd: Command) -> str: """ Returns the command usage signature. - This is a custom implementation of ``command.signature`` in - order to format the command signature without aliases. - - Parameters - ---------- - cmd: :class:`discord.ext.commands.Command` - The command object to get the parameters of. - - Returns - ------- - str + This is a custom implementation of `command.signature` in order to format the command + signature without aliases. """ - results = [] for name, param in cmd.clean_params.items(): @@ -346,16 +244,8 @@ class HelpSession: return f"{cmd.name} {' '.join(results)}" - async def build_pages(self): - """ - Builds the list of content pages to be paginated through in the - help message. - - Returns - ------- - list[str] - """ - + async def build_pages(self) -> None: + """Builds the list of content pages to be paginated through in the help message, as a list of str.""" # Use LinePaginator to restrict embed line height paginator = LinePaginator(prefix='', suffix='', max_lines=self._max_lines) @@ -482,20 +372,8 @@ class HelpSession: # save organised pages to session self._pages = paginator.pages - def embed_page(self, page_number=0): - """ - Returns an Embed with the requested page formatted within. - - Parameters - ---------- - page_number: int - The page to be retrieved. Zero indexed. - - Returns - ------- - :class:`discord.Embed` - """ - + def embed_page(self, page_number: int = 0) -> Embed: + """Returns an Embed with the requested page formatted within.""" embed = Embed() # if command or cog, add query to title for pages other than first @@ -514,17 +392,8 @@ class HelpSession: return embed - async def update_page(self, page_number=0): - """ - Sends the intial message, or changes the existing one to the - given page number. - - Parameters - ---------- - page_number: int - The page number to show in the help message. - """ - + async def update_page(self, page_number: int = 0) -> None: + """Sends the intial message, or changes the existing one to the given page number.""" self._current_page = page_number embed_page = self.embed_page(page_number) @@ -534,47 +403,27 @@ class HelpSession: await self.message.edit(embed=embed_page) @classmethod - async def start(cls, ctx, *command, **options): - """ - Create and begin a help session based on the given command - context. - - Parameters - ---------- - ctx: :class:`discord.ext.commands.Context` - The context of the invoked help command. - *command: str - A variable argument of the command being queried. - cleanup: Optional[bool] - Set to ``True`` to have the message deleted on session end. - Defaults to ``False``. - only_can_run: Optional[bool] - Set to ``True`` to hide commands the user can't run. - Defaults to ``False``. - show_hidden: Optional[bool] - Set to ``True`` to include hidden commands. - Defaults to ``False``. - max_lines: Optional[int] - Sets the max number of lines the paginator will add to a - single page. - Defaults to 20. - - Returns - ------- - :class:`HelpSession` + async def start(cls, ctx: Context, *command, **options) -> "HelpSession": """ + Create and begin a help session based on the given command context. + Available options kwargs: + * cleanup: Optional[bool] + Set to `True` to have the message deleted on session end. Defaults to `False`. + * only_can_run: Optional[bool] + Set to `True` to hide commands the user can't run. Defaults to `False`. + * show_hidden: Optional[bool] + Set to `True` to include hidden commands. Defaults to `False`. + * max_lines: Optional[int] + Sets the max number of lines the paginator will add to a single page. Defaults to 20. + """ session = cls(ctx, *command, **options) await session.prepare() return session - async def stop(self): - """ - Stops the help session, removes event listeners and attempts to - delete the help message. - """ - + async def stop(self) -> None: + """Stops the help session, removes event listeners and attempts to delete the help message.""" self._bot.remove_listener(self.on_reaction_add) self._bot.remove_listener(self.on_message_delete) @@ -586,80 +435,47 @@ class HelpSession: await self.message.clear_reactions() @property - def is_first_page(self): - """ - A bool reflecting if session is currently showing the first page. - - Returns - ------- - bool - """ - + def is_first_page(self) -> bool: + """Check if session is currently showing the first page.""" return self._current_page == 0 @property - def is_last_page(self): - """ - A bool reflecting if the session is currently showing the last page. - - Returns - ------- - bool - """ - + def is_last_page(self) -> bool: + """Check if the session is currently showing the last page.""" return self._current_page == (len(self._pages)-1) - async def do_first(self): - """ - Event that is called when the user requests the first page. - """ - + async def do_first(self) -> None: + """Event that is called when the user requests the first page.""" if not self.is_first_page: await self.update_page(0) - async def do_back(self): - """ - Event that is called when the user requests the previous page. - """ - + async def do_back(self) -> None: + """Event that is called when the user requests the previous page.""" if not self.is_first_page: await self.update_page(self._current_page-1) - async def do_next(self): - """ - Event that is called when the user requests the next page. - """ - + async def do_next(self) -> None: + """Event that is called when the user requests the next page.""" if not self.is_last_page: await self.update_page(self._current_page+1) - async def do_end(self): - """ - Event that is called when the user requests the last page. - """ - + async def do_end(self) -> None: + """Event that is called when the user requests the last page.""" if not self.is_last_page: await self.update_page(len(self._pages)-1) - async def do_stop(self): - """ - Event that is called when the user requests to stop the help session. - """ - + async def do_stop(self) -> None: + """Event that is called when the user requests to stop the help session.""" await self.message.delete() -class Help: - """ - Custom Embed Pagination Help feature - """ +class Help(DiscordCog): + """Custom Embed Pagination Help feature.""" + @commands.command('help') @redirect_output(destination_channel=Channels.bot, bypass_roles=STAFF_ROLES) - async def new_help(self, ctx, *commands): - """ - Shows Command Help. - """ - + async def new_help(self, ctx: Context, *commands) -> None: + """Shows Command Help.""" try: await HelpSession.start(ctx, *commands) except HelpQueryNotFound as error: @@ -674,42 +490,29 @@ class Help: await ctx.send(embed=embed) -def unload(bot): +def unload(bot: Bot) -> None: """ Reinstates the original help command. - This is run if the cog raises an exception on load, or if the - extension is unloaded. - - Parameters - ---------- - bot: :class:`discord.ext.commands.Bot` - The discord bot client. + This is run if the cog raises an exception on load, or if the extension is unloaded. """ - bot.remove_command('help') bot.add_command(bot._old_help) -def setup(bot): +def setup(bot: Bot) -> None: """ The setup for the help extension. This is called automatically on `bot.load_extension` being run. - Stores the original help command instance on the ``bot._old_help`` - attribute for later reinstatement, before removing it from the - command registry so the new help command can be loaded successfully. - - If an exception is raised during the loading of the cog, ``unload`` - will be called in order to reinstate the original help command. + Stores the original help command instance on the `bot._old_help` attribute for later + reinstatement, before removing it from the command registry so the new help command can be + loaded successfully. - Parameters - ---------- - bot: `discord.ext.commands.Bot` - The discord bot client. + If an exception is raised during the loading of the cog, `unload` will be called in order to + reinstate the original help command. """ - bot._old_help = bot.get_command('help') bot.remove_command('help') @@ -720,18 +523,12 @@ def setup(bot): raise -def teardown(bot): +def teardown(bot: Bot) -> None: """ The teardown for the help extension. This is called automatically on `bot.unload_extension` being run. - Calls ``unload`` in order to reinstate the original help command. - - Parameters - ---------- - bot: `discord.ext.commands.Bot` - The discord bot client. + Calls `unload` in order to reinstate the original help command. """ - unload(bot) diff --git a/bot/cogs/information.py b/bot/cogs/information.py index 469999c00..60aec6219 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -1,40 +1,27 @@ 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 discord.ext.commands import Bot, Cog, Context, command -from bot.constants import ( - Channels, Emojis, Keys, MODERATION_ROLES, - NEGATIVE_REPLIES, STAFF_ROLES, URLs -) -from bot.decorators import with_role +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: - """ - A cog with commands for generating embeds with - server information, such as server statistics - and user 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") - async def roles_info(self, ctx: Context): - """ - Returns a list of all roles and their - corresponding IDs. - """ - + async def roles_info(self, ctx: Context) -> None: + """Returns a list of all roles and their corresponding IDs.""" # Sort the roles alphabetically and remove the @everyone role roles = sorted(ctx.guild.roles, key=lambda role: role.name) roles = [role for role in roles if role.name != "@everyone"] @@ -56,12 +43,8 @@ class Information: await ctx.send(embed=embed) @command(name="server", aliases=["server_info", "guild", "guild_info"]) - async def server_info(self, ctx: Context): - """ - Returns an embed full of - server information. - """ - + async def server_info(self, ctx: Context) -> None: + """Returns an embed full of server information.""" created = time_since(ctx.guild.created_at, precision="days") features = ", ".join(ctx.guild.features) region = ctx.guild.region @@ -125,35 +108,27 @@ class Information: await ctx.send(embed=embed) @command(name="user", aliases=["user_info", "member", "member_info"]) - async def user_info(self, ctx: Context, user: Member = None, hidden: bool = False): - """ - 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. + async def user_info(self, ctx: Context, user: Member = None, hidden: bool = False) -> None: + """Returns info about a user.""" + 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})" @@ -161,19 +136,17 @@ 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 - api_response = await self.bot.http_session.get( - url=URLs.site_infractions_user.format(user_id=user.id), - params={"hidden": hidden}, - headers=self.headers + infractions = await self.bot.api_client.get( + 'bot/infractions', + params={ + 'hidden': str(hidden), + 'user__id': str(user.id) + } ) - infractions = await api_response.json() - infr_total = 0 infr_active = 0 @@ -208,24 +181,8 @@ class Information: await ctx.send(embed=embed) - @user_info.error - async def user_info_command_error(self, ctx: Context, error: CommandError): - 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): +def setup(bot: Bot) -> None: + """Information cog load.""" bot.add_cog(Information(bot)) log.info("Cog loaded: Information") diff --git a/bot/cogs/jams.py b/bot/cogs/jams.py index 96b98e559..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,35 +10,33 @@ from bot.decorators import with_role log = logging.getLogger(__name__) -class CodeJams: - """ - Manages the code-jam related parts of our server - """ +class CodeJams(commands.Cog): + """Manages the code-jam related parts of our server.""" def __init__(self, bot: commands.Bot): self.bot = bot @commands.command() @with_role(Roles.admin) - async def createteam( - self, ctx: commands.Context, - team_name: str, members: commands.Greedy[Member] - ): + async def createteam(self, ctx: commands.Context, team_name: str, members: commands.Greedy[Member]) -> None: """ - Create a team channel (both voice and text) in the Code Jams category, assign roles - and then add overwrites for the team. + 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") @@ -65,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 ) @@ -102,9 +101,14 @@ 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): +def setup(bot: commands.Bot) -> None: + """Code Jams cog load.""" bot.add_cog(CodeJams(bot)) log.info("Cog loaded: CodeJams") diff --git a/bot/cogs/logging.py b/bot/cogs/logging.py index 6b8462f3b..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,28 +9,32 @@ from bot.constants import Channels, DEBUG_MODE log = logging.getLogger(__name__) -class Logging: - """ - Debug logging module - """ +class Logging(Cog): + """Debug logging module.""" def __init__(self, bot: Bot): self.bot = bot - async def on_ready(self): + @Cog.listener() + async def on_ready(self) -> None: + """Announce our presence to the configured devlog channel.""" log.info("Bot connected!") embed = Embed(description="Connected!") embed.set_author( name="Python Bot", - url="https://gitlab.com/discord-python/projects/bot", - icon_url="https://gitlab.com/python-discord/branding/raw/master/logos/logo_circle/logo_circle.png" + url="https://github.com/python-discord/bot", + icon_url=( + "https://raw.githubusercontent.com/" + "python-discord/branding/master/logos/logo_circle/logo_circle_large.png" + ) ) if not DEBUG_MODE: await self.bot.get_channel(Channels.devlog).send(embed=embed) -def setup(bot): +def setup(bot: Bot) -> None: + """Logging cog load.""" bot.add_cog(Logging(bot)) log.info("Cog loaded: Logging") diff --git a/bot/cogs/moderation.py b/bot/cogs/moderation.py index 73359c88c..81b3864a7 100644 --- a/bot/cogs/moderation.py +++ b/bot/cogs/moderation.py @@ -1,25 +1,25 @@ import asyncio import logging import textwrap -from typing import Union +from datetime import datetime +from typing import Dict, Union -from aiohttp import ClientError 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 from bot.cogs.modlog import ModLog -from bot.constants import Colours, Event, Icons, Keys, MODERATION_ROLES, URLs -from bot.converters import InfractionSearchQuery +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 parse_rfc1123, wait_until +from bot.utils.time import wait_until log = logging.getLogger(__name__) @@ -28,11 +28,12 @@ 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") def proxy_user(user_id: str) -> Object: + """Create a proxy user for the provided user_id for situations where a Member or User object cannot be resolved.""" try: user_id = int(user_id) except ValueError: @@ -46,54 +47,41 @@ def proxy_user(user_id: str) -> Object: UserTypes = Union[Member, User, proxy_user] -class Moderation(Scheduler): - """ - Server moderation tools. - """ +class Moderation(Scheduler, Cog): + """Server moderation tools.""" def __init__(self, bot: Bot): self.bot = bot - self.headers = {"X-API-KEY": Keys.site_api} self._muted_role = Object(constants.Roles.muted) super().__init__() @property def mod_log(self) -> ModLog: + """Get currently loaded ModLog cog instance.""" return self.bot.get_cog("ModLog") - async def on_ready(self): + @Cog.listener() + async def on_ready(self) -> None: + """Schedule expiration for previous infractions.""" # Schedule expiration for previous infractions - response = await self.bot.http_session.get( - URLs.site_infractions, - params={"dangling": "true"}, - headers=self.headers + infractions = await self.bot.api_client.get( + 'bot/infractions', params={'active': 'true'} ) - infraction_list = await response.json() - for infraction_object in infraction_list: - if infraction_object["expires_at"] is not None: - self.schedule_task(self.bot.loop, infraction_object["id"], infraction_object) + for infraction in infractions: + if infraction["expires_at"] is not None: + self.schedule_task(self.bot.loop, infraction["id"], infraction) # region: Permanent infractions @with_role(*MODERATION_ROLES) @command() - async def warn(self, ctx: Context, user: UserTypes, *, reason: str = 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: + async def warn(self, ctx: Context, user: UserTypes, *, reason: str = None) -> 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}" @@ -122,33 +110,23 @@ 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 kick(self, ctx: Context, user: Member, *, reason: str = None): - """ - Kicks a user. - - **`user`:** Accepts user mention, ID, etc. - **`reason`:** The reason for the kick. - """ - + async def kick(self, ctx: Context, user: Member, *, reason: str = None) -> None: + """Kicks a user with the provided reason.""" if not await self.respect_role_hierarchy(ctx, user, 'kick'): # 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="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) @@ -182,32 +160,28 @@ 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 ban(self, ctx: Context, user: UserTypes, *, reason: str = None): - """ - Create a permanent ban infraction in the database for a user. - - **`user`:** Accepts user mention, ID, etc. - **`reason`:** The reason for the ban. - """ - + async def ban(self, ctx: Context, user: UserTypes, *, reason: str = None) -> None: + """Create a permanent ban infraction for a user with the provided reason.""" if not await self.respect_role_hierarchy(ctx, user, 'ban'): # 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) - 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) + if infraction is None: return notified = await self.notify_infraction( user=user, infr_type="Ban", - duration="Permanent", reason=reason ) @@ -246,21 +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): - """ - Create a permanent mute infraction in the database for a user. - - **`user`:** Accepts user mention, ID, etc. - **`reason`:** The reason for the mute. - """ + async def mute(self, ctx: Context, user: Member, *, reason: str = None) -> None: + """Create a permanent mute infraction for a user with the provided reason.""" + 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) @@ -269,7 +240,7 @@ class Moderation(Scheduler): notified = await self.notify_infraction( user=user, infr_type="Mute", - duration="Permanent", + expires_at="Permanent", reason=reason ) @@ -300,7 +271,7 @@ class Moderation(Scheduler): Reason: {reason} """), content=log_content, - footer=f"ID {response_object['infraction']['id']}" + footer=f"ID {infraction['id']}" ) # endregion @@ -308,19 +279,19 @@ class Moderation(Scheduler): @with_role(*MODERATION_ROLES) @command() - async def tempmute(self, ctx: Context, user: Member, duration: str, *, reason: str = None): + async def tempmute(self, ctx: Context, user: Member, duration: ExpirationDate, *, reason: str = None) -> None: """ - Create a temporary mute infraction in the database for a user. + Create a temporary mute infraction for a user with the provided expiration and reason. - **`user`:** Accepts user mention, ID, etc. - **`duration`:** The duration for the temporary mute infraction - **`reason`:** The reason for the temporary mute. + Duration strings are parsed per: http://strftime.org/ """ + expiration = duration - response_object = await post_infraction( - ctx, user, type="mute", reason=reason, duration=duration - ) - 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, expires_at=expiration) + if infraction is None: return self.mod_log.ignore(Event.member_update, user.id) @@ -329,14 +300,17 @@ class Moderation(Scheduler): notified = await self.notify_infraction( user=user, infr_type="Mute", - duration=duration, + expires_at=expiration, 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_task(ctx.bot.loop, infraction_object["id"], infraction_object) + self.schedule_task(ctx.bot.loop, infraction["id"], infraction) dm_result = ":incoming_envelope: " if notified else "" action = f"{dm_result}:ok_hand: muted {user.mention} until {infraction_expiration}" @@ -363,41 +337,38 @@ class Moderation(Scheduler): Actor: {ctx.message.author} DM: {dm_status} Reason: {reason} - Duration: {duration} Expires: {infraction_expiration} """), content=log_content, - footer=f"ID {response_object['infraction']['id']}" + footer=f"ID {infraction['id']}" ) @with_role(*MODERATION_ROLES) @command() - async def tempban( - self, ctx: Context, user: UserTypes, duration: str, *, reason: str = None - ): + async def tempban(self, ctx: Context, user: UserTypes, duration: ExpirationDate, *, reason: str = None) -> None: """ - Create a temporary ban infraction in the database for a user. + Create a temporary ban infraction for a user with the provided expiration and reason. - **`user`:** Accepts user mention, ID, etc. - **`duration`:** The duration for the temporary ban infraction - **`reason`:** The reason for the temporary ban. + 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 - response_object = await post_infraction( - ctx, user, type="ban", reason=reason, duration=duration - ) - 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) + if infraction is None: return notified = await self.notify_infraction( user=user, infr_type="Ban", - duration=duration, + expires_at=expiration, reason=reason ) @@ -410,10 +381,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_task(ctx.bot.loop, infraction_object["id"], infraction_object) + self.schedule_task(ctx.bot.loop, infraction["id"], infraction) dm_result = ":incoming_envelope: " if notified else "" action = f"{dm_result}:ok_hand: banned {user.mention} until {infraction_expiration}" @@ -439,11 +413,10 @@ class Moderation(Scheduler): Actor: {ctx.message.author} DM: {dm_status} Reason: {reason} - Duration: {duration} Expires: {infraction_expiration} """), content=log_content, - footer=f"ID {response_object['infraction']['id']}" + footer=f"ID {infraction['id']}" ) # endregion @@ -451,18 +424,14 @@ class Moderation(Scheduler): @with_role(*MODERATION_ROLES) @command(hidden=True, aliases=['shadowwarn', 'swarn', 'shadow_warn']) - async def note(self, ctx: Context, user: UserTypes, *, reason: str = None): + async def note(self, ctx: Context, user: UserTypes, *, reason: str = None) -> None: """ - Create a private infraction note in the database for a user. + Create a private infraction note in the database for a user with the provided reason. - **`user`:** accepts user mention, ID, etc. - **`reason`:** The reason for the warning. + 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: @@ -480,26 +449,24 @@ 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) @command(hidden=True, aliases=['shadowkick', 'skick']) - async def shadow_kick(self, ctx: Context, user: Member, *, reason: str = None): + async def shadow_kick(self, ctx: Context, user: Member, *, reason: str = None) -> None: """ - Kicks a user. + Kick a user for the provided reason. - **`user`:** accepts user mention, ID, etc. - **`reason`:** The reason for the kick. + This does not send the user a notification. """ - if not await self.respect_role_hierarchy(ctx, user, 'shadowkick'): # 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="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) @@ -533,26 +500,27 @@ 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(hidden=True, aliases=['shadowban', 'sban']) - async def shadow_ban(self, ctx: Context, user: UserTypes, *, reason: str = None): + async def shadow_ban(self, ctx: Context, user: UserTypes, *, reason: str = None) -> None: """ - Create a permanent ban infraction in the database for a user. + Create a permanent ban infraction for a user with the provided reason. - **`user`:** Accepts user mention, ID, etc. - **`reason`:** The reason for the ban. + This does not send the user a notification. """ - if not await self.respect_role_hierarchy(ctx, user, 'shadowban'): # 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, 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) @@ -587,21 +555,22 @@ 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(hidden=True, aliases=['shadowmute', 'smute']) - async def shadow_mute(self, ctx: Context, user: Member, *, reason: str = None): + async def shadow_mute(self, ctx: Context, user: Member, *, reason: str = None) -> None: """ - Create a permanent mute infraction in the database for a user. + Create a permanent mute infraction for a user with the provided reason. - **`user`:** Accepts user mention, ID, etc. - **`reason`:** The reason for the mute. + This does not send the user a notification. """ + if await already_has_active_infraction(ctx=ctx, user=user, type="mute"): + return - response_object = await post_infraction(ctx, user, type="mute", reason=reason, hidden=True) - if response_object is None: + 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) @@ -622,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 @@ -631,29 +600,34 @@ 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 in the database for a user. + Create a temporary mute infraction for a user with the provided reason. - **`user`:** Accepts user mention, ID, etc. - **`duration`:** The duration for the temporary mute infraction - **`reason`:** The reason for the temporary mute. + Duration strings are parsed per: http://strftime.org/ + + This does not send the user a notification. """ + expiration = duration - response_object = await post_infraction( - ctx, user, type="mute", reason=reason, duration=duration, 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, 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}.") @@ -671,34 +645,35 @@ 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 in the database for a user. + Create a temporary ban infraction for a user with the provided reason. - **`user`:** Accepts user mention, ID, etc. - **`duration`:** The duration for the temporary ban infraction - **`reason`:** The reason for the temporary ban. + Duration strings are parsed per: http://strftime.org/ + + 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) @@ -710,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}.") @@ -739,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 @@ -751,40 +728,32 @@ class Moderation(Scheduler): @with_role(*MODERATION_ROLES) @command() - async def unmute(self, ctx: Context, user: Member): - """ - Deactivates the active mute infraction for a user. - - **`user`:** Accepts user mention, ID, etc. - """ - + async def unmute(self, ctx: Context, user: UserTypes) -> None: + """Deactivates the active mute infraction for a user.""" try: # check the current active infraction - response = await self.bot.http_session.get( - URLs.site_infractions_user_type_current.format( - user_id=user.id, - infraction_type="mute" - ), - headers=self.headers + response = await self.bot.api_client.get( + 'bot/infractions', + params={ + 'active': 'true', + 'type': 'mute', + 'user__id': user.id + } ) + if len(response) > 1: + log.warning("Found more than one active mute infraction for user `%d`", user.id) - response_object = await response.json() - if "error_code" in response_object: - return await ctx.send( - ":x: There was an error removing the infraction: " - f"{response_object['error_message']}" - ) - - infraction_object = response_object["infraction"] - if infraction_object is None: + 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 - await self._deactivate_infraction(infraction_object) - if infraction_object["expires_at"] is not None: - self.cancel_expiration(infraction_object["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, @@ -804,61 +773,82 @@ 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_object['expires_at']} - DM: {dm_status} - """), - footer=infraction_object["id"], + text=embed_text, + footer=footer, content=log_content ) - - except Exception as e: - log.exception("There was an error removing an infraction.", exc_info=e) + except Exception: + log.exception("There was an error removing an infraction.") await ctx.send(":x: There was an error removing the infraction.") @with_role(*MODERATION_ROLES) @command() - async def unban(self, ctx: Context, user: UserTypes): - """ - Deactivates the active ban infraction for a user. - - **`user`:** Accepts user mention, ID, etc. - """ - + async def unban(self, ctx: Context, user: UserTypes) -> None: + """Deactivates the active ban infraction for a user.""" try: # check the current active infraction - response = await self.bot.http_session.get( - URLs.site_infractions_user_type_current.format( - user_id=user.id, - infraction_type="ban" - ), - headers=self.headers + response = await self.bot.api_client.get( + 'bot/infractions', + params={ + 'active': 'true', + 'type': 'ban', + 'user__id': str(user.id) + } ) - response_object = await response.json() - if "error_code" in response_object: - return await ctx.send( - ":x: There was an error removing the infraction: " - f"{response_object['error_message']}" + if len(response) > 1: + log.warning( + "More than one active ban infraction found for user `%d`.", + user.id ) - infraction_object = response_object["infraction"] - if infraction_object is None: + 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 + + for infraction in response: + await self._deactivate_infraction(infraction) + if infraction["expires_at"] is not None: + self.cancel_expiration(infraction["id"]) - await self._deactivate_infraction(infraction_object) - if infraction_object["expires_at"] is not None: - self.cancel_expiration(infraction_object["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}.") @@ -868,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_object['expires_at']} - """) + text=embed_text, + footer=footer, ) except Exception: log.exception("There was an error removing an infraction.") @@ -883,70 +870,68 @@ class Moderation(Scheduler): @with_role(*MODERATION_ROLES) @group(name='infraction', aliases=('infr', 'infractions', 'inf'), invoke_without_command=True) - async def infraction_group(self, ctx: Context): + async def infraction_group(self, ctx: Context) -> None: """Infraction manipulation commands.""" - await ctx.invoke(self.bot.get_command("help"), "infraction") @with_role(*MODERATION_ROLES) @infraction_group.group(name='edit', invoke_without_command=True) - async def infraction_edit_group(self, ctx: Context): + async def infraction_edit_group(self, ctx: Context) -> None: """Infraction editing commands.""" - await ctx.invoke(self.bot.get_command("help"), "infraction", "edit") @with_role(*MODERATION_ROLES) @infraction_edit_group.command(name="duration") - async def edit_duration(self, ctx: Context, infraction_id: str, duration: str): + async def edit_duration( + self, ctx: Context, + infraction_id: int, expires_at: Union[ExpirationDate, str] + ) -> None: """ - Sets the duration of the given infraction, relative to the time of - updating. + Sets the duration of the given infraction, relative to the time of updating. - **`infraction_id`:** The ID (UUID) of the infraction. - **`duration`:** The new duration of the infraction, relative to the - time of updating. Use "permanent" to the infraction as permanent. + Duration strings are parsed per: http://strftime.org/, use "permanent" to mark the infraction as permanent. """ + if isinstance(expires_at, str) and expires_at != 'permanent': + raise BadArgument( + "If `expires_at` is given as a non-datetime, " + "it must be `permanent`." + ) + if expires_at == 'permanent': + expires_at = None try: - previous = await self.bot.http_session.get( - URLs.site_infractions_by_id.format( - infraction_id=infraction_id - ), - headers=self.headers + previous_infraction = await self.bot.api_client.get( + 'bot/infractions/' + str(infraction_id) ) - previous_object = await previous.json() - - if duration == "permanent": - duration = None # check the current active infraction - response = await self.bot.http_session.patch( - URLs.site_infractions, + infraction = await self.bot.api_client.patch( + 'bot/infractions/' + str(infraction_id), json={ - "id": infraction_id, - "duration": duration - }, - headers=self.headers + 'expires_at': ( + expires_at.isoformat() + if expires_at is not None + else None + ) + } ) - response_object = await response.json() - if "error_code" in response_object or response_object.get("success") is False: - return await ctx.send( - ":x: There was an error updating the infraction: " - f"{response_object['error_message']}" - ) - infraction_object = response_object["infraction"] # Re-schedule - self.cancel_task(infraction_id) + self.cancel_task(infraction['id']) loop = asyncio.get_event_loop() - self.schedule_task(loop, infraction_object["id"], infraction_object) + self.schedule_task(loop, infraction['id'], infraction) - if duration is None: + if expires_at is None: await ctx.send(f":ok_hand: Updated infraction: marked as permanent.") else: + human_expiry = ( + datetime + .fromisoformat(infraction['expires_at'][:-1]) + .strftime('%c') + ) await ctx.send( ":ok_hand: Updated infraction: set to expire on " - f"{infraction_object['expires_at']}." + f"{human_expiry}." ) except Exception: @@ -954,10 +939,8 @@ class Moderation(Scheduler): await ctx.send(":x: There was an error updating the infraction.") return - prev_infraction = previous_object["infraction"] - # Get information about the infraction's user - user_id = int(infraction_object["user"]["user_id"]) + user_id = infraction["user"] user = ctx.guild.get_member(user_id) if user: @@ -968,7 +951,7 @@ class Moderation(Scheduler): thumbnail = None # The infraction's actor - actor_id = int(infraction_object["actor"]["user_id"]) + actor_id = infraction["actor"] actor = ctx.guild.get_member(actor_id) or f"`{actor_id}`" await self.mod_log.send_log_message( @@ -980,55 +963,33 @@ class Moderation(Scheduler): Member: {member_text} Actor: {actor} Edited by: {ctx.message.author} - Previous expiry: {prev_infraction['expires_at']} - New expiry: {infraction_object['expires_at']} + Previous expiry: {previous_infraction['expires_at']} + New expiry: {infraction['expires_at']} """) ) @with_role(*MODERATION_ROLES) @infraction_edit_group.command(name="reason") - async def edit_reason(self, ctx: Context, infraction_id: str, *, reason: str): - """ - Sets the reason of the given infraction. - **`infraction_id`:** The ID (UUID) of the infraction. - **`reason`:** The new reason of the infraction. - """ - + async def edit_reason(self, ctx: Context, infraction_id: int, *, reason: str) -> None: + """Edit the reason of the given infraction.""" try: - previous = await self.bot.http_session.get( - URLs.site_infractions_by_id.format( - infraction_id=infraction_id - ), - headers=self.headers + old_infraction = await self.bot.api_client.get( + 'bot/infractions/' + str(infraction_id) ) - previous_object = await previous.json() - - response = await self.bot.http_session.patch( - URLs.site_infractions, - json={ - "id": infraction_id, - "reason": reason - }, - headers=self.headers + updated_infraction = await self.bot.api_client.patch( + 'bot/infractions/' + str(infraction_id), + json={'reason': reason} ) - response_object = await response.json() - if "error_code" in response_object or response_object.get("success") is False: - return await ctx.send( - ":x: There was an error updating the infraction: " - f"{response_object['error_message']}" - ) - await ctx.send(f":ok_hand: Updated infraction: set reason to \"{reason}\".") + except Exception: log.exception("There was an error updating an infraction.") - return await ctx.send(":x: There was an error updating the infraction.") - - new_infraction = response_object["infraction"] - prev_infraction = previous_object["infraction"] + await ctx.send(":x: There was an error updating the infraction.") + return # Get information about the infraction's user - user_id = int(new_infraction["user"]["user_id"]) + user_id = updated_infraction['user'] user = ctx.guild.get_member(user_id) if user: @@ -1039,7 +1000,7 @@ class Moderation(Scheduler): thumbnail = None # The infraction's actor - actor_id = int(new_infraction["actor"]["user_id"]) + actor_id = updated_infraction['actor'] actor = ctx.guild.get_member(actor_id) or f"`{actor_id}`" await self.mod_log.send_log_message( @@ -1051,8 +1012,8 @@ class Moderation(Scheduler): Member: {user_text} Actor: {actor} Edited by: {ctx.message.author} - Previous reason: {prev_infraction['reason']} - New reason: {new_infraction['reason']} + Previous reason: {old_infraction['reason']} + New reason: {updated_infraction['reason']} """) ) @@ -1061,11 +1022,8 @@ class Moderation(Scheduler): @with_role(*MODERATION_ROLES) @infraction_group.group(name="search", invoke_without_command=True) - async def infraction_search_group(self, ctx: Context, query: InfractionSearchQuery): - """ - Searches for infractions in the database. - """ - + async def infraction_search_group(self, ctx: Context, query: InfractionSearchQuery) -> None: + """Searches for infractions in the database.""" if isinstance(query, User): await ctx.invoke(self.search_user, query) @@ -1074,72 +1032,44 @@ class Moderation(Scheduler): @with_role(*MODERATION_ROLES) @infraction_search_group.command(name="user", aliases=("member", "id")) - async def search_user(self, ctx: Context, user: Union[User, proxy_user]): - """ - Search for infractions by member. - """ - - try: - response = await self.bot.http_session.get( - URLs.site_infractions_user.format( - user_id=user.id - ), - params={"hidden": "True"}, - headers=self.headers - ) - infraction_list = await response.json() - except ClientError: - log.exception(f"Failed to fetch infractions for user {user} ({user.id}).") - await ctx.send(":x: An error occurred while fetching infractions.") - return - + async def search_user(self, ctx: Context, user: Union[User, proxy_user]) -> None: + """Search for infractions by member.""" + infraction_list = await self.bot.api_client.get( + 'bot/infractions', + params={'user__id': str(user.id)} + ) embed = Embed( title=f"Infractions for {user} ({len(infraction_list)} total)", colour=Colour.orange() ) - await self.send_infraction_list(ctx, embed, infraction_list) @with_role(*MODERATION_ROLES) @infraction_search_group.command(name="reason", aliases=("match", "regex", "re")) - async def search_reason(self, ctx: Context, reason: str): - """ - Search for infractions by their reason. Use Re2 for matching. - """ - - try: - response = await self.bot.http_session.get( - URLs.site_infractions, - params={"search": reason, "hidden": "True"}, - headers=self.headers - ) - infraction_list = await response.json() - except ClientError: - log.exception(f"Failed to fetch infractions matching reason `{reason}`.") - await ctx.send(":x: An error occurred while fetching infractions.") - return - + async def search_reason(self, ctx: Context, reason: str) -> None: + """Search for infractions by their reason. Use Re2 for matching.""" + infraction_list = await self.bot.api_client.get( + 'bot/infractions', params={'search': reason} + ) embed = Embed( title=f"Infractions matching `{reason}` ({len(infraction_list)} total)", colour=Colour.orange() ) - await self.send_infraction_list(ctx, embed, infraction_list) # endregion # region: Utility functions - async def send_infraction_list(self, ctx: Context, embed: Embed, infractions: list): - + async def send_infraction_list(self, ctx: Context, embed: Embed, infractions: list) -> None: + """Send a paginated embed of infractions for the specified user.""" if not infractions: await ctx.send(f":warning: No infractions could be found for that query.") return - lines = [] - for infraction in infractions: - lines.append( - self._infraction_to_string(infraction) - ) + lines = tuple( + self._infraction_to_string(infraction) + for infraction in infractions + ) await LinePaginator.paginate( lines, @@ -1153,14 +1083,10 @@ class Moderation(Scheduler): # endregion # region: Utility functions - def schedule_expiration(self, loop: asyncio.AbstractEventLoop, infraction_object: dict): - """ - Schedules a task to expire a temporary infraction. - - :param loop: the asyncio event loop - :param infraction_object: the infraction object to expire at the end of the task - """ - + 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: return @@ -1169,12 +1095,8 @@ class Moderation(Scheduler): self.scheduled_tasks[infraction_id] = task - def cancel_expiration(self, infraction_id: str): - """ - Un-schedules a task set to expire a temporary infraction. - :param infraction_id: the ID of the infraction in question - """ - + def cancel_expiration(self, infraction_id: str) -> None: + """Un-schedules a task set to expire a temporary infraction.""" task = self.scheduled_tasks.get(infraction_id) if task is None: log.warning(f"Failed to unschedule {infraction_id}: no task found.") @@ -1183,19 +1105,17 @@ class Moderation(Scheduler): log.debug(f"Unscheduled {infraction_id}.") del self.scheduled_tasks[infraction_id] - async def _scheduled_task(self, infraction_object: dict): + async def _scheduled_task(self, infraction_object: Dict[str, Union[str, int, bool]]) -> None: """ - A co-routine which marks an infraction as expired after the delay from the time of - scheduling to the time of expiration. At the time of expiration, the infraction is - marked as inactive on the website, and the expiration task is cancelled. + Marks an infraction expired after the delay from time of scheduling to time of expiration. - :param infraction_object: the infraction in question + At the time of expiration, the infraction is marked as inactive on the website, and the + expiration task is cancelled. The user is then notified via DM. """ - infraction_id = infraction_object["id"] # transform expiration to delay in seconds - expiration_datetime = parse_rfc1123(infraction_object["expires_at"]) + expiration_datetime = datetime.fromisoformat(infraction_object["expires_at"][:-1]) await wait_until(expiration_datetime) log.debug(f"Marking infraction {infraction_id} as inactive (expired).") @@ -1204,7 +1124,7 @@ class Moderation(Scheduler): self.cancel_task(infraction_object["id"]) # Notify the user that they've been unmuted. - user_id = int(infraction_object["user"]["user_id"]) + user_id = infraction_object["user"] guild = self.bot.get_guild(constants.Guild.id) await self.notify_pardon( user=guild.get_member(user_id), @@ -1213,16 +1133,14 @@ class Moderation(Scheduler): icon_url=Icons.user_unmute ) - async def _deactivate_infraction(self, infraction_object): + 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. This co-routine does - not cancel or un-schedule an expiration task. + A co-routine which marks an infraction as inactive on the website. - :param infraction_object: the infraction in question + This co-routine does not cancel or un-schedule an expiration task. """ - guild: Guild = self.bot.get_guild(constants.Guild.id) - user_id = int(infraction_object["user"]["user_id"]) + user_id = infraction_object["user"] infraction_type = infraction_object["type"] if infraction_type == "mute": @@ -1235,24 +1153,29 @@ 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) - - await self.bot.http_session.patch( - URLs.site_infractions, - headers=self.headers, - json={ - "id": infraction_object["id"], - "active": False - } + 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): - actor_id = int(infraction_object["actor"]["user_id"]) + 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) actor = guild.get_member(actor_id) - active = infraction_object["active"] is True - user_id = int(infraction_object["user"]["user_id"]) - hidden = infraction_object.get("hidden", False) is True + active = infraction_object["active"] + user_id = infraction_object["user"] + hidden = infraction_object["hidden"] + created = datetime.fromisoformat(infraction_object["inserted_at"][:-1]).strftime("%Y-%m-%d %H:%M") + if infraction_object["expires_at"] is None: + expires = "*Permanent*" + else: + expires = datetime.fromisoformat(infraction_object["expires_at"][:-1]).strftime("%Y-%m-%d %H:%M") lines = textwrap.dedent(f""" {"**===============**" if active else "==============="} @@ -1261,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 "==============="} @@ -1271,28 +1194,24 @@ class Moderation(Scheduler): return lines.strip() async def notify_infraction( - self, user: Union[User, Member], infr_type: str, duration: str = None, - reason: str = None - ): + self, + user: Union[User, Member], + infr_type: str, + expires_at: Union[datetime, str] = 'N/A', + reason: str = "No reason provided." + ) -> bool: """ - Notify a user of their fresh infraction :) + Attempt to notify a user, via DM, of their fresh infraction. - :param user: The user to send the message to. - :param infr_type: The type of infraction, as a string. - :param duration: The duration of the infraction. - :param reason: The reason for the infraction. + Returns a boolean indicator of whether the DM was successful. """ - - if duration is None: - duration = "N/A" - - if reason is None: - reason = "No reason provided." + if isinstance(expires_at, datetime): + expires_at = expires_at.strftime('%c') embed = Embed( description=textwrap.dedent(f""" **Type:** {infr_type} - **Duration:** {duration} + **Expires:** {expires_at} **Reason:** {reason} """), colour=Colour(Colours.soft_red) @@ -1309,18 +1228,17 @@ 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: """ - Notify a user that an infraction has been lifted. + Attempt to notify a user, via DM, of their expired infraction. - :param user: The user to send the message to. - :param title: The title of the embed. - :param content: The content of the embed. - :param icon_url: URL for the title icon. + Optionally returns a boolean indicator of whether the DM was successful. """ - embed = Embed( description=content, colour=Colour(Colours.soft_green) @@ -1330,16 +1248,14 @@ class Moderation(Scheduler): return await self.send_private_embed(user, embed) - async def send_private_embed(self, user: Union[User, Member], embed: Embed): + async def send_private_embed(self, user: Union[User, Member], embed: Embed) -> bool: """ A helper method for sending an embed to a user's DMs. - :param user: The user to send the embed to. - :param embed: The embed to send. + 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) @@ -1351,7 +1267,8 @@ class Moderation(Scheduler): ) return False - async def log_notify_failure(self, target: str, actor: Member, infraction_type: str): + async def log_notify_failure(self, target: str, actor: Member, infraction_type: str) -> None: + """Send a mod log entry if an attempt to DM the target user has failed.""" await self.mod_log.send_log_message( icon_url=Icons.token_removed, content=actor.mention, @@ -1365,23 +1282,23 @@ class Moderation(Scheduler): # endregion - async def __error(self, ctx, error): + @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. + If this check fails, a warning is sent to the invoking ctx. Returns True always if target is not a discord.Member instance. - - :param ctx: The command context when invoked. - :param target: The target of the infraction. - :param infr_type: The type of infraction. """ - if not isinstance(target, Member): return True @@ -1400,6 +1317,7 @@ class Moderation(Scheduler): return target_is_lower -def setup(bot): +def setup(bot: Bot) -> None: + """Moderation cog load.""" bot.add_cog(Moderation(bot)) log.info("Cog loaded: Moderation") diff --git a/bot/cogs/modlog.py b/bot/cogs/modlog.py index b3094321e..68424d268 100644 --- a/bot/cogs/modlog.py +++ b/bot/cogs/modlog.py @@ -1,9 +1,8 @@ import asyncio -import datetime import logging +from datetime import datetime from typing import List, Optional, Union -from aiohttp import ClientResponseError from dateutil.relativedelta import relativedelta from deepdiff import DeepDiff from discord import ( @@ -12,12 +11,10 @@ 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, Context from bot.constants import ( - Channels, Colours, Emojis, - Event, Guild as GuildConstant, Icons, - Keys, Roles, URLs + Channels, Colours, Emojis, Event, Guild as GuildConstant, Icons, URLs ) from bot.utils.time import humanize_delta @@ -27,77 +24,50 @@ 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: - """ - Logging for server events and staff actions - """ +class ModLog(Cog, name="ModLog"): + """Logging for server events and staff actions.""" def __init__(self, bot: Bot): self.bot = bot - self.headers = {"X-API-KEY": Keys.site_api} self._ignored = {event: [] for event in Event} self._cached_deletes = [] self._cached_edits = [] - async def upload_log(self, messages: List[Message]) -> Optional[str]: + async def upload_log(self, messages: List[Message], actor_id: int) -> str: """ - Uploads the log data to the database via - an API endpoint for uploading logs. + Uploads the log data to the database via an API endpoint for uploading logs. Used in several mod log embeds. Returns a URL that can be used to view the log. """ - - log_data = [] - - for message in messages: - author = f"{message.author.name}#{message.author.discriminator}" - - # message.author may return either a User or a Member. Users don't have roles. - if type(message.author) is User: - role_id = Roles.developer - else: - role_id = message.author.top_role.id - - content = message.content - embeds = [embed.to_dict() for embed in message.embeds] - attachments = ["<Attachment>" for _ in message.attachments] - - log_data.append({ - "content": content, - "author": author, - "user_id": str(message.author.id), - "role_id": str(role_id), - "timestamp": message.created_at.strftime("%D %H:%M"), - "attachments": attachments, - "embeds": embeds, - }) - - response = await self.bot.http_session.post( - URLs.site_logs_api, - headers=self.headers, - json={"log_data": log_data} + response = await self.bot.api_client.post( + 'bot/deleted-messages', + json={ + 'actor': actor_id, + 'creation': datetime.utcnow().isoformat(), + 'deletedmessage_set': [ + { + 'id': message.id, + 'author': message.author.id, + 'channel_id': message.channel.id, + 'content': message.content, + 'embeds': [embed.to_dict() for embed in message.embeds] + } + for message in messages + ] + } ) - try: - data = await response.json() - log_id = data["log_id"] - except (KeyError, ClientResponseError): - log.debug( - "API returned an unexpected result:\n" - f"{response.text}" - ) - return + return f"{URLs.site_logs_view}/{response['id']}" - return f"{URLs.site_logs_view}/{log_id}" - - def ignore(self, event: Event, *items: int): + def ignore(self, event: Event, *items: int) -> None: + """Add event to ignored events to suppress log emission.""" for item in items: if item not in self._ignored[event]: self._ignored[event].append(item) @@ -115,17 +85,17 @@ class ModLog: content: Optional[str] = None, additional_embeds: Optional[List[Embed]] = None, additional_embeds_msg: Optional[str] = None, - timestamp_override: Optional[datetime.datetime] = None, + timestamp_override: Optional[datetime] = None, footer: Optional[str] = None, - ): + ) -> Context: + """Generate log embed and send to logging channel.""" embed = Embed(description=text) if title and icon_url: embed.set_author(name=title, icon_url=icon_url) embed.colour = colour - - embed.timestamp = timestamp_override or datetime.datetime.utcnow() + embed.timestamp = timestamp_override or datetime.utcnow() if footer: embed.set_footer(text=footer) @@ -150,7 +120,9 @@ class ModLog: return await self.bot.get_context(log_message) # Optionally return for use with antispam - async def on_guild_channel_create(self, channel: GUILD_CHANNEL): + @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: return @@ -174,7 +146,9 @@ class ModLog: await self.send_log_message(Icons.hash_green, Colour(Colours.soft_green), title, message) - async def on_guild_channel_delete(self, channel: GUILD_CHANNEL): + @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: return @@ -195,7 +169,9 @@ class ModLog: title, message ) - async def on_guild_channel_update(self, before: GUILD_CHANNEL, after: GuildChannel): + @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: return @@ -253,7 +229,9 @@ class ModLog: "Channel updated", message ) - async def on_guild_role_create(self, role: Role): + @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: return @@ -262,7 +240,9 @@ class ModLog: "Role created", f"`{role.id}`" ) - async def on_guild_role_delete(self, role: Role): + @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: return @@ -271,7 +251,9 @@ class ModLog: "Role removed", f"{role.name} (`{role.id}`)" ) - async def on_guild_role_update(self, before: Role, after: Role): + @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: return @@ -322,7 +304,9 @@ class ModLog: "Role updated", message ) - async def on_guild_update(self, before: Guild, after: Guild): + @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: return @@ -371,7 +355,9 @@ class ModLog: thumbnail=after.icon_url_as(format="png") ) - async def on_member_ban(self, guild: Guild, member: Union[Member, User]): + @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: return @@ -386,12 +372,14 @@ class ModLog: channel_id=Channels.modlog ) - async def on_member_join(self, member: Member): + @Cog.listener() + async def on_member_join(self, member: Member) -> None: + """Log member join event to user log.""" if member.guild.id != GuildConstant.id: return message = f"{member.name}#{member.discriminator} (`{member.id}`)" - now = datetime.datetime.utcnow() + now = datetime.utcnow() difference = abs(relativedelta(now, member.created_at)) message += "\n\n**Account age:** " + humanize_delta(difference) @@ -406,7 +394,9 @@ class ModLog: channel_id=Channels.userlog ) - async def on_member_remove(self, member: Member): + @Cog.listener() + async def on_member_remove(self, member: Member) -> None: + """Log member leave event to user log.""" if member.guild.id != GuildConstant.id: return @@ -421,7 +411,9 @@ class ModLog: channel_id=Channels.userlog ) - async def on_member_unban(self, guild: Guild, member: User): + @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: return @@ -436,7 +428,9 @@ class ModLog: channel_id=Channels.modlog ) - async def on_member_update(self, before: Member, after: Member): + @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: return @@ -525,7 +519,9 @@ class ModLog: channel_id=Channels.userlog ) - async def on_message_delete(self, message: Message): + @Cog.listener() + async def on_message_delete(self, message: Message) -> None: + """Log message delete event to message change log.""" channel = message.channel author = message.author @@ -556,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", @@ -576,7 +575,9 @@ class ModLog: channel_id=Channels.message_log ) - async def on_raw_message_delete(self, event: RawMessageDeleteEvent): + @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: return @@ -615,7 +616,9 @@ class ModLog: channel_id=Channels.message_log ) - async def on_message_edit(self, before: Message, after: Message): + @Cog.listener() + async def on_message_edit(self, before: Message, after: Message) -> None: + """Log message edit event to message change log.""" if ( not before.guild or before.guild.id != GuildConstant.id @@ -688,10 +691,12 @@ class ModLog: channel_id=Channels.message_log, timestamp_override=after.edited_at ) - async def on_raw_message_edit(self, event: RawMessageUpdateEvent): + @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 @@ -757,6 +762,7 @@ class ModLog: ) -def setup(bot): +def setup(bot: Bot) -> None: + """Mod log cog load.""" bot.add_cog(ModLog(bot)) log.info("Cog loaded: ModLog") diff --git a/bot/cogs/off_topic_names.py b/bot/cogs/off_topic_names.py index 9b0f5d6c5..8f1af347a 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, URLs +from bot.constants import Channels, MODERATION_ROLES from bot.decorators import with_role from bot.pagination import LinePaginator @@ -18,7 +19,8 @@ class OffTopicName(Converter): """A converter that ensures an added off-topic name is valid.""" @staticmethod - async def convert(ctx: Context, argument: str): + async def convert(ctx: Context, argument: str) -> str: + """Attempt to replace any invalid characters with their approximate Unicode equivalent.""" allowed_characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ!?'`-" if not (2 <= len(argument) <= 96): @@ -37,16 +39,8 @@ class OffTopicName(Converter): return argument.translate(table) -async def update_names(bot: Bot, headers: dict): - """ - The background updater task that performs a channel name update daily. - - Args: - bot (Bot): - The running bot instance, used for fetching data from the - website via the bot's `http_session`. - """ - +async def update_names(bot: Bot) -> None: + """Background updater task that performs the daily channel name update.""" while True: # Since we truncate the compute timedelta to seconds, we add one second to ensure # we go past midnight in the `seconds_to_sleep` set below. @@ -55,11 +49,9 @@ async def update_names(bot: Bot, headers: dict): seconds_to_sleep = (next_midnight - datetime.utcnow()).seconds + 1 await asyncio.sleep(seconds_to_sleep) - response = await bot.http_session.get( - f'{URLs.site_off_topic_names_api}?random_items=3', - headers=headers + channel_0_name, channel_1_name, channel_2_name = await bot.api_client.get( + 'bot/off-topic-channel-names', params={'random_items': 3} ) - channel_0_name, channel_1_name, channel_2_name = await response.json() channel_0, channel_1, channel_2 = (bot.get_channel(channel_id) for channel_id in CHANNELS) await channel_0.edit(name=f'ot0-{channel_0_name}') @@ -71,101 +63,100 @@ async def update_names(bot: Bot, headers: dict): ) -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): + def cog_unload(self) -> None: + """Cancel any running updater tasks on cog unload.""" if self.updater_task is not None: self.updater_task.cancel() - async def on_ready(self): + @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) @with_role(*MODERATION_ROLES) - async def otname_group(self, ctx): + async def otname_group(self, ctx: Context) -> None: """Add or list items from the off-topic channel name rotation.""" - await ctx.invoke(self.bot.get_command("help"), "otname") @otname_group.command(name='add', aliases=('a',)) @with_role(*MODERATION_ROLES) - async def add_command(self, ctx, name: OffTopicName): + 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) - result = await self.bot.http_session.post( - URLs.site_off_topic_names_api, - headers=self.headers, - params={'name': name} + 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}" ) - - response = await result.json() - - if result.status == 200: - log.info( - f"{ctx.author.name}#{ctx.author.discriminator}" - f" added the off-topic channel name '{name}" - ) - await ctx.send(":ok_hand:") - else: - error_reason = response.get('message', "No reason provided.") - await ctx.send(f":warning: got non-200 from the API: {error_reason}") + 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, name: OffTopicName): + 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) - result = await self.bot.http_session.delete( - URLs.site_off_topic_names_api, - headers=self.headers, - params={'name': name} + 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}" ) - - response = await result.json() - - if result.status == 200: - if response['deleted'] == 0: - await ctx.send(f":warning: No name matching `{name}` was found in the database.") - else: - log.info( - f"{ctx.author.name}#{ctx.author.discriminator}" - f" deleted the off-topic channel name '{name}" - ) - await ctx.send(":ok_hand:") - else: - error_reason = response.get('message', "No reason provided.") - await ctx.send(f":warning: got non-200 from the API: {error_reason}") + await ctx.send(f":ok_hand: Removed `{name}` from the names list.") @otname_group.command(name='list', aliases=('l',)) @with_role(*MODERATION_ROLES) - async def list_command(self, ctx): + async def list_command(self, ctx: Context) -> None: """ Lists all currently known off-topic channel names in a paginator. + Restricted to Moderator and above to not spoil the surprise. """ - - result = await self.bot.http_session.get( - URLs.site_off_topic_names_api, - headers=self.headers + result = await self.bot.api_client.get('bot/off-topic-channel-names') + lines = sorted(f"• {name}" for name in result) + embed = Embed( + title=f"Known off-topic names (`{len(result)}` total)", + colour=Colour.blue() ) - response = await result.json() - lines = sorted(f"• {name}" for name in response) + if result: + await LinePaginator.paginate(lines, ctx, embed, max_size=400, empty=False) + else: + 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"Known off-topic names (`{len(response)}` total)", + title=f"Query results", colour=Colour.blue() ) - await LinePaginator.paginate(lines, ctx, embed, max_size=400, empty=False) + + 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): +def setup(bot: Bot) -> None: + """Off topic names cog load.""" bot.add_cog(OffTopicNames(bot)) log.info("Cog loaded: OffTopicNames") diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py index b5bd26e3d..63a57c5c6 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -3,9 +3,10 @@ import logging import random import textwrap from datetime import datetime, timedelta +from typing import List -from discord import Colour, Embed, TextChannel -from discord.ext.commands import Bot, Context, group +from discord import Colour, Embed, Message, TextChannel +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,10 +16,8 @@ from bot.pagination import LinePaginator log = logging.getLogger(__name__) -class Reddit: - """ - Track subreddit posts and show detailed statistics about them. - """ +class Reddit(Cog): + """Track subreddit posts and show detailed statistics about them.""" HEADERS = {"User-Agent": "Discord Bot: PythonDiscord (https://pythondiscord.com/)"} URL = "https://www.reddit.com" @@ -34,11 +33,8 @@ class Reddit: self.new_posts_task = None self.top_weekly_posts_task = None - async def fetch_posts(self, route: str, *, amount: int = 25, params=None): - """ - A helper method to fetch a certain amount of Reddit posts at a given route. - """ - + async def fetch_posts(self, route: str, *, amount: int = 25, params: dict = None) -> List[dict]: + """A helper method to fetch a certain amount of Reddit posts at a given route.""" # Reddit's JSON responses only provide 25 posts at most. if not 25 >= amount > 0: raise ValueError("Invalid amount of subreddit posts requested.") @@ -57,11 +53,10 @@ class Reddit: return posts[:amount] - async def send_top_posts(self, channel: TextChannel, subreddit: Subreddit, content=None, time="all"): - """ - Create an embed for the top posts, then send it in a given TextChannel. - """ - + async def send_top_posts( + self, channel: TextChannel, subreddit: Subreddit, content: str = None, time: str = "all" + ) -> Message: + """Create an embed for the top posts, then send it in a given TextChannel.""" # Create the new spicy embed. embed = Embed() embed.description = "" @@ -115,11 +110,8 @@ class Reddit: embed=embed ) - async def poll_new_posts(self): - """ - Periodically search for new subreddit posts. - """ - + async def poll_new_posts(self) -> None: + """Periodically search for new subreddit posts.""" while True: await asyncio.sleep(RedditConfig.request_delay) @@ -179,11 +171,8 @@ class Reddit: log.trace(f"Sent {len(new_posts)} new {subreddit} posts to channel {self.reddit_channel.id}.") - async def poll_top_weekly_posts(self): - """ - Post a summary of the top posts every week. - """ - + async def poll_top_weekly_posts(self) -> None: + """Post a summary of the top posts every week.""" while True: now = datetime.utcnow() @@ -214,19 +203,13 @@ class Reddit: await message.pin() @group(name="reddit", invoke_without_command=True) - async def reddit_group(self, ctx: Context): - """ - View the top posts from various subreddits. - """ - + async def reddit_group(self, ctx: Context) -> None: + """View the top posts from various subreddits.""" await ctx.invoke(self.bot.get_command("help"), "reddit") @reddit_group.command(name="top") - async def top_command(self, ctx: Context, subreddit: Subreddit = "r/Python"): - """ - Send the top posts of all time from a given subreddit. - """ - + async def top_command(self, ctx: Context, subreddit: Subreddit = "r/Python") -> None: + """Send the top posts of all time from a given subreddit.""" await self.send_top_posts( channel=ctx.channel, subreddit=subreddit, @@ -235,11 +218,8 @@ class Reddit: ) @reddit_group.command(name="daily") - async def daily_command(self, ctx: Context, subreddit: Subreddit = "r/Python"): - """ - Send the top posts of today from a given subreddit. - """ - + async def daily_command(self, ctx: Context, subreddit: Subreddit = "r/Python") -> None: + """Send the top posts of today from a given subreddit.""" await self.send_top_posts( channel=ctx.channel, subreddit=subreddit, @@ -248,11 +228,8 @@ class Reddit: ) @reddit_group.command(name="weekly") - async def weekly_command(self, ctx: Context, subreddit: Subreddit = "r/Python"): - """ - Send the top posts of this week from a given subreddit. - """ - + async def weekly_command(self, ctx: Context, subreddit: Subreddit = "r/Python") -> None: + """Send the top posts of this week from a given subreddit.""" await self.send_top_posts( channel=ctx.channel, subreddit=subreddit, @@ -262,11 +239,8 @@ class Reddit: @with_role(*STAFF_ROLES) @reddit_group.command(name="subreddits", aliases=("subs",)) - async def subreddits_command(self, ctx: Context): - """ - Send a paginated embed of all the subreddits we're relaying. - """ - + async def subreddits_command(self, ctx: Context) -> None: + """Send a paginated embed of all the subreddits we're relaying.""" embed = Embed() embed.title = "Relayed subreddits." embed.colour = Colour.blurple() @@ -279,8 +253,10 @@ class Reddit: max_lines=15 ) - async def on_ready(self): - self.reddit_channel = self.bot.get_channel(Channels.reddit) + @Cog.listener() + async def on_ready(self) -> None: + """Initiate reddit post event loop.""" + self.reddit_channel = await self.bot.fetch_channel(Channels.reddit) if self.reddit_channel is not None: if self.new_posts_task is None: @@ -291,6 +267,7 @@ class Reddit: log.warning("Couldn't locate a channel for subreddit relaying.") -def setup(bot): +def setup(bot: Bot) -> None: + """Reddit cog load.""" bot.add_cog(Reddit(bot)) log.info("Cog loaded: Reddit") diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index e8177107b..8460de91f 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -1,22 +1,21 @@ import asyncio -import datetime import logging import random import textwrap +from datetime import datetime +from operator import itemgetter +from typing import Optional -from aiohttp import ClientResponseError from dateutil.relativedelta import relativedelta -from discord import Colour, Embed -from discord.ext.commands import Bot, Context, group +from discord import Colour, Embed, Message +from discord.ext.commands import Bot, Cog, Context, group -from bot.constants import ( - Channels, Icons, Keys, NEGATIVE_REPLIES, - POSITIVE_REPLIES, STAFF_ROLES, URLs -) +from bot.constants import Channels, Icons, NEGATIVE_REPLIES, POSITIVE_REPLIES, STAFF_ROLES +from bot.converters import ExpirationDate from bot.pagination import LinePaginator from bot.utils.checks import without_role_check from bot.utils.scheduling import Scheduler -from bot.utils.time import humanize_delta, parse_rfc1123, wait_until +from bot.utils.time import humanize_delta, wait_until log = logging.getLogger(__name__) @@ -24,28 +23,26 @@ 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 - self.headers = {"X-API-Key": Keys.site_api} super().__init__() - async def on_ready(self): - # Get all the current reminders for re-scheduling - response = await self.bot.http_session.get( - url=URLs.site_reminders_api, - headers=self.headers + @Cog.listener() + async def on_ready(self) -> None: + """Get all current reminders from the API and reschedule them.""" + response = await self.bot.api_client.get( + 'bot/reminders', + params={'active': 'true'} ) - response_data = await response.json() - - # Find the current time, timezone-aware. - now = datetime.datetime.utcnow().replace(tzinfo=datetime.timezone.utc) + now = datetime.utcnow() loop = asyncio.get_event_loop() - for reminder in response_data["reminders"]: - remind_at = parse_rfc1123(reminder["remind_at"]) + for reminder in response: + remind_at = datetime.fromisoformat(reminder['expiration'][:-1]) # If the reminder is already overdue ... if remind_at < now: @@ -56,43 +53,18 @@ class Reminders(Scheduler): self.schedule_task(loop, reminder["id"], reminder) @staticmethod - async def _send_confirmation(ctx: Context, response: dict, on_success: str): - """ - Send an embed confirming whether or not a change was made successfully. - - :return: A Boolean value indicating whether it failed (True) or passed (False) - """ - + async def _send_confirmation(ctx: Context, on_success: str) -> None: + """Send an embed confirming the reminder change was made successfully.""" embed = Embed() - - if not response.get("success"): - embed.colour = Colour.red() - embed.title = random.choice(NEGATIVE_REPLIES) - embed.description = response.get("error_message", "An unexpected error occurred.") - - log.warn(f"Unable to create/edit/delete a reminder. Response: {response}") - failed = True - - else: - embed.colour = Colour.green() - embed.title = random.choice(POSITIVE_REPLIES) - embed.description = on_success - - failed = False - + embed.colour = Colour.green() + embed.title = random.choice(POSITIVE_REPLIES) + embed.description = on_success await ctx.send(embed=embed) - return failed - - async def _scheduled_task(self, reminder: dict): - """ - A coroutine which sends the reminder once the time is reached. - - :param reminder: the data of the reminder. - :return: - """ + async def _scheduled_task(self, reminder: dict) -> None: + """A coroutine which sends the reminder once the time is reached, and cancels the running task.""" reminder_id = reminder["id"] - reminder_datetime = parse_rfc1123(reminder["remind_at"]) + reminder_datetime = datetime.fromisoformat(reminder['expiration'][:-1]) # Send the reminder message once the desired duration has passed await wait_until(reminder_datetime) @@ -104,51 +76,24 @@ class Reminders(Scheduler): # Now we can begone with it from our schedule list. self.cancel_task(reminder_id) - async def _delete_reminder(self, reminder_id: str): - """ - Delete a reminder from the database, given its ID. - - :param reminder_id: The ID of the reminder. - """ - - # The API requires a list, so let's give it one :) - json_data = { - "reminders": [ - reminder_id - ] - } - - await self.bot.http_session.delete( - url=URLs.site_reminders_api, - headers=self.headers, - json=json_data - ) + async def _delete_reminder(self, reminder_id: str) -> None: + """Delete a reminder from the database, given its ID, and cancel the running task.""" + await self.bot.api_client.delete('bot/reminders/' + str(reminder_id)) # Now we can remove it from the schedule list self.cancel_task(reminder_id) - async def _reschedule_reminder(self, reminder): - """ - Reschedule a reminder object. - - :param reminder: The reminder to be rescheduled. - """ - + async def _reschedule_reminder(self, reminder: dict) -> None: + """Reschedule a reminder object.""" loop = asyncio.get_event_loop() self.cancel_task(reminder["id"]) self.schedule_task(loop, reminder["id"], reminder) - async def send_reminder(self, reminder, late: relativedelta = None): - """ - Send the reminder. - - :param reminder: The data about the reminder. - :param late: How late the reminder is (if at all) - """ - - channel = self.bot.get_channel(int(reminder["channel_id"])) - user = self.bot.get_user(int(reminder["user_id"])) + async def send_reminder(self, reminder: dict, late: relativedelta = None) -> None: + """Send the reminder.""" + channel = self.bot.get_channel(reminder["channel_id"]) + user = self.bot.get_user(reminder["author"]) embed = Embed() embed.colour = Colour.blurple() @@ -173,19 +118,17 @@ class Reminders(Scheduler): await self._delete_reminder(reminder["id"]) @group(name="remind", aliases=("reminder", "reminders"), invoke_without_command=True) - async def remind_group(self, ctx: Context, duration: str, *, content: str): - """ - Commands for managing your reminders. - """ - - await ctx.invoke(self.new_reminder, duration=duration, content=content) + async def remind_group(self, ctx: Context, expiration: ExpirationDate, *, content: str) -> None: + """Commands for managing your reminders.""" + await ctx.invoke(self.new_reminder, expiration=expiration, content=content) @remind_group.command(name="new", aliases=("add", "create")) - async def new_reminder(self, ctx: Context, duration: str, *, content: str): + async def new_reminder(self, ctx: Context, expiration: ExpirationDate, *, content: str) -> Optional[Message]: """ Set yourself a simple reminder. - """ + Expiration is parsed per: http://strftime.org/ + """ embed = Embed() # If the user is not staff, we need to verify whether or not to make a reminder at all. @@ -200,13 +143,13 @@ class Reminders(Scheduler): return await ctx.send(embed=embed) # Get their current active reminders - response = await self.bot.http_session.get( - url=URLs.site_reminders_user_api.format(user_id=ctx.author.id), - headers=self.headers + active_reminders = await self.bot.api_client.get( + 'bot/reminders', + params={ + 'user__id': str(ctx.author.id) + } ) - active_reminders = await response.json() - # Let's limit this, so we don't get 10 000 # reminders from kip or something like that :P if len(active_reminders) > MAXIMUM_REMINDERS: @@ -217,78 +160,53 @@ class Reminders(Scheduler): return await ctx.send(embed=embed) # Now we can attempt to actually set the reminder. - try: - response = await self.bot.http_session.post( - url=URLs.site_reminders_api, - headers=self.headers, - json={ - "user_id": str(ctx.author.id), - "duration": duration, - "content": content, - "channel_id": str(ctx.channel.id) - } - ) - - response_data = await response.json() - - # AFAIK only happens if the user enters, like, a quintillion weeks - except ClientResponseError: - embed.colour = Colour.red() - embed.title = random.choice(NEGATIVE_REPLIES) - embed.description = ( - "An error occurred while adding your reminder to the database. " - "Did you enter a reasonable duration?" - ) - - log.warn(f"User {ctx.author} attempted to create a reminder for {duration}, but failed.") - - return await ctx.send(embed=embed) - - # Confirm to the user whether or not it worked. - failed = await self._send_confirmation( - ctx, response_data, - on_success="Your reminder has been created successfully!" + reminder = await self.bot.api_client.post( + 'bot/reminders', + json={ + 'author': ctx.author.id, + 'channel_id': ctx.message.channel.id, + 'content': content, + 'expiration': expiration.isoformat() + } ) - # If it worked, schedule the reminder. - if not failed: - loop = asyncio.get_event_loop() - reminder = response_data["reminder"] + # Confirm to the user that it worked. + await self._send_confirmation( + ctx, on_success="Your reminder has been created successfully!" + ) - self.schedule_task(loop, reminder["id"], reminder) + loop = asyncio.get_event_loop() + self.schedule_task(loop, reminder["id"], reminder) @remind_group.command(name="list") - async def list_reminders(self, ctx: Context): - """ - View a paginated embed of all reminders for your user. - """ - + async def list_reminders(self, ctx: Context) -> Optional[Message]: + """View a paginated embed of all reminders for your user.""" # Get all the user's reminders from the database. - response = await self.bot.http_session.get( - url=URLs.site_reminders_user_api, - params={"user_id": str(ctx.author.id)}, - headers=self.headers + data = await self.bot.api_client.get( + 'bot/reminders', + params={'user__id': str(ctx.author.id)} ) - data = await response.json() - now = datetime.datetime.utcnow().replace(tzinfo=datetime.timezone.utc) + now = datetime.utcnow() # Make a list of tuples so it can be sorted by time. - reminders = [ - (rem["content"], rem["remind_at"], rem["friendly_id"]) for rem in data["reminders"] - ] - - reminders.sort(key=lambda rem: rem[1]) + reminders = sorted( + ( + (rem['content'], rem['expiration'], rem['id']) + for rem in data + ), + key=itemgetter(1) + ) lines = [] - for index, (content, remind_at, friendly_id) in enumerate(reminders): + for content, remind_at, id_ in reminders: # Parse and humanize the time, make it pretty :D - remind_datetime = parse_rfc1123(remind_at) + remind_datetime = datetime.fromisoformat(remind_at[:-1]) time = humanize_delta(relativedelta(remind_datetime, now)) text = textwrap.dedent(f""" - **Reminder #{index}:** *expires in {time}* (ID: {friendly_id}) + **Reminder #{id_}:** *expires in {time}* (ID: {id_}) {content} """).strip() @@ -314,93 +232,55 @@ class Reminders(Scheduler): ) @remind_group.group(name="edit", aliases=("change", "modify"), invoke_without_command=True) - async def edit_reminder_group(self, ctx: Context): - """ - Commands for modifying your current reminders. - """ - + async def edit_reminder_group(self, ctx: Context) -> None: + """Commands for modifying your current reminders.""" await ctx.invoke(self.bot.get_command("help"), "reminders", "edit") @edit_reminder_group.command(name="duration", aliases=("time",)) - async def edit_reminder_duration(self, ctx: Context, friendly_id: str, duration: str): - """ - Edit one of your reminders' duration. + async def edit_reminder_duration(self, ctx: Context, id_: int, expiration: ExpirationDate) -> None: """ + Edit one of your reminder's expiration. + Expiration is parsed per: http://strftime.org/ + """ # Send the request to update the reminder in the database - response = await self.bot.http_session.patch( - url=URLs.site_reminders_user_api, - headers=self.headers, - json={ - "user_id": str(ctx.author.id), - "friendly_id": friendly_id, - "duration": duration - } + reminder = await self.bot.api_client.patch( + 'bot/reminders/' + str(id_), + json={'expiration': expiration.isoformat()} ) # Send a confirmation message to the channel - response_data = await response.json() - failed = await self._send_confirmation( - ctx, response_data, - on_success="That reminder has been edited successfully!" + await self._send_confirmation( + ctx, on_success="That reminder has been edited successfully!" ) - if not failed: - await self._reschedule_reminder(response_data["reminder"]) + await self._reschedule_reminder(reminder) @edit_reminder_group.command(name="content", aliases=("reason",)) - async def edit_reminder_content(self, ctx: Context, friendly_id: str, *, content: str): - """ - Edit one of your reminders' content. - """ - + async def edit_reminder_content(self, ctx: Context, id_: int, *, content: str) -> None: + """Edit one of your reminder's content.""" # Send the request to update the reminder in the database - response = await self.bot.http_session.patch( - url=URLs.site_reminders_user_api, - headers=self.headers, - json={ - "user_id": str(ctx.author.id), - "friendly_id": friendly_id, - "content": content - } + reminder = await self.bot.api_client.patch( + 'bot/reminders/' + str(id_), + json={'content': content} ) # Send a confirmation message to the channel - response_data = await response.json() - failed = await self._send_confirmation( - ctx, response_data, - on_success="That reminder has been edited successfully!" + await self._send_confirmation( + ctx, on_success="That reminder has been edited successfully!" ) - - if not failed: - await self._reschedule_reminder(response_data["reminder"]) + await self._reschedule_reminder(reminder) @remind_group.command("delete", aliases=("remove",)) - async def delete_reminder(self, ctx: Context, friendly_id: str): - """ - Delete one of your active reminders. - """ - - # Send the request to delete the reminder from the database - response = await self.bot.http_session.delete( - url=URLs.site_reminders_user_api, - headers=self.headers, - json={ - "user_id": str(ctx.author.id), - "friendly_id": friendly_id - } - ) - - response_data = await response.json() - failed = await self._send_confirmation( - ctx, response_data, - on_success="That reminder has been deleted successfully!" + async def delete_reminder(self, ctx: Context, id_: int) -> None: + """Delete one of your active reminders.""" + await self._delete_reminder(id_) + await self._send_confirmation( + ctx, on_success="That reminder has been deleted successfully!" ) - if not failed: - await self._delete_reminder(response_data["reminder_id"]) - -def setup(bot: Bot): +def setup(bot: Bot) -> None: + """Reminders cog load.""" bot.add_cog(Reminders(bot)) log.info("Cog loaded: Reminders") diff --git a/bot/cogs/rmq.py b/bot/cogs/rmq.py deleted file mode 100644 index 585eacc25..000000000 --- a/bot/cogs/rmq.py +++ /dev/null @@ -1,229 +0,0 @@ -import asyncio -import datetime -import json -import logging -import pprint - -import aio_pika -from aio_pika import Message -from dateutil import parser as date_parser -from discord import Colour, Embed -from discord.ext.commands import Bot -from discord.utils import get - -from bot.constants import Channels, Guild, RabbitMQ - -log = logging.getLogger(__name__) - -LEVEL_COLOURS = { - "debug": Colour.blue(), - "info": Colour.green(), - "warning": Colour.gold(), - "error": Colour.red() -} - -DEFAULT_LEVEL_COLOUR = Colour.greyple() -EMBED_PARAMS = ( - "colour", "title", "url", "description", "timestamp" -) - -CONSUME_TIMEOUT = datetime.timedelta(seconds=10) - - -class RMQ: - """ - RabbitMQ event handling - """ - - rmq = None # type: aio_pika.Connection - channel = None # type: aio_pika.Channel - queue = None # type: aio_pika.Queue - - def __init__(self, bot: Bot): - self.bot = bot - - async def on_ready(self): - self.rmq = await aio_pika.connect_robust( - host=RabbitMQ.host, port=RabbitMQ.port, login=RabbitMQ.username, password=RabbitMQ.password - ) - - log.info("Connected to RabbitMQ") - - self.channel = await self.rmq.channel() - self.queue = await self.channel.declare_queue("bot_events", durable=True) - - log.debug("Channel opened, queue declared") - - async for message in self.queue: - with message.process(): - message.ack() - await self.handle_message(message, message.body.decode()) - - async def send_text(self, queue: str, data: str): - message = Message(data.encode("utf-8")) - await self.channel.default_exchange.publish(message, queue) - - async def send_json(self, queue: str, **data): - message = Message(json.dumps(data).encode("utf-8")) - await self.channel.default_exchange.publish(message, queue) - - async def consume(self, queue: str, **kwargs): - queue_obj = await self.channel.declare_queue(queue, **kwargs) - - result = None - start_time = datetime.datetime.now() - - while result is None: - if datetime.datetime.now() - start_time >= CONSUME_TIMEOUT: - result = "Timed out while waiting for a response." - else: - result = await queue_obj.get(timeout=5, fail=False) - await asyncio.sleep(0.5) - - if result: - result.ack() - - return result - - async def handle_message(self, message, data): - log.debug(f"Message: {message}") - log.debug(f"Data: {data}") - - try: - data = json.loads(data) - except Exception: - await self.do_mod_log("error", "Unable to parse event", data) - else: - event = data["event"] - event_data = data["data"] - - try: - func = getattr(self, f"do_{event}") - await func(**event_data) - except Exception as e: - await self.do_mod_log( - "error", f"Unable to handle event: {event}", - str(e) - ) - - async def do_mod_log(self, level: str, title: str, message: str): - colour = LEVEL_COLOURS.get(level, DEFAULT_LEVEL_COLOUR) - embed = Embed( - title=title, description=f"```\n{message}\n```", - colour=colour, timestamp=datetime.datetime.utcnow() - ) - - await self.bot.get_channel(Channels.devlog).send(embed=embed) - log.log(logging._nameToLevel[level.upper()], f"Modlog: {title} | {message}") - - async def do_send_message(self, target: int, message: str): - channel = self.bot.get_channel(target) - - if channel is None: - await self.do_mod_log( - "error", "Failed: Send Message", - f"Unable to find channel: {target}" - ) - else: - await channel.send(message) - - await self.do_mod_log( - "info", "Succeeded: Send Embed", - f"Message sent to channel {target}\n\n{message}" - ) - - async def do_send_embed(self, target: int, **embed_params): - for param, value in list(embed_params.items()): # To keep a full copy - if param not in EMBED_PARAMS: - await self.do_mod_log( - "warning", "Warning: Send Embed", - f"Unknown embed parameter: {param}" - ) - del embed_params[param] - - if param == "timestamp": - embed_params[param] = date_parser.parse(value) - elif param == "colour": - embed_params[param] = Colour(value) - - channel = self.bot.get_channel(target) - - if channel is None: - await self.do_mod_log( - "error", "Failed: Send Embed", - f"Unable to find channel: {target}" - ) - else: - await channel.send(embed=Embed(**embed_params)) - - await self.do_mod_log( - "info", "Succeeded: Send Embed", - f"Embed sent to channel {target}\n\n{pprint.pformat(embed_params, 4)}" - ) - - async def do_add_role(self, target: int, role_id: int, reason: str): - guild = self.bot.get_guild(Guild.id) - member = guild.get_member(int(target)) - - if member is None: - return await self.do_mod_log( - "error", "Failed: Add Role", - f"Unable to find member: {target}" - ) - - role = get(guild.roles, id=int(role_id)) - - if role is None: - return await self.do_mod_log( - "error", "Failed: Add Role", - f"Unable to find role: {role_id}" - ) - - try: - await member.add_roles(role, reason=reason) - except Exception as e: - await self.do_mod_log( - "error", "Failed: Add Role", - f"Error while adding role {role.name}: {e}" - ) - else: - await self.do_mod_log( - "info", "Succeeded: Add Role", - f"Role {role.name} added to member {target}" - ) - - async def do_remove_role(self, target: int, role_id: int, reason: str): - guild = self.bot.get_guild(Guild.id) - member = guild.get_member(int(target)) - - if member is None: - return await self.do_mod_log( - "error", "Failed: Remove Role", - f"Unable to find member: {target}" - ) - - role = get(guild.roles, id=int(role_id)) - - if role is None: - return await self.do_mod_log( - "error", "Failed: Remove Role", - f"Unable to find role: {role_id}" - ) - - try: - await member.remove_roles(role, reason=reason) - except Exception as e: - await self.do_mod_log( - "error", "Failed: Remove Role", - f"Error while adding role {role.name}: {e}" - ) - else: - await self.do_mod_log( - "info", "Succeeded: Remove Role", - f"Role {role.name} removed from member {target}" - ) - - -def setup(bot): - bot.add_cog(RMQ(bot)) - log.info("Cog loaded: RMQ") diff --git a/bot/cogs/rules.py b/bot/cogs/rules.py deleted file mode 100644 index b8a26ff76..000000000 --- a/bot/cogs/rules.py +++ /dev/null @@ -1,104 +0,0 @@ -import re -from typing import Optional - -from discord import Colour, Embed -from discord.ext.commands import Bot, Context, command - -from bot.constants import Channels, STAFF_ROLES -from bot.decorators import redirect_output -from bot.pagination import LinePaginator - - -class Rules: - - def __init__(self, bot: Bot): - self.bot = bot - - # We'll get the rules from the API when the - # site has been updated to the Django Framework. - # Hard-code the rules for now until the new RulesView is released. - - self.rules = ( - "Be polite, and do not spam.", - - "Follow the [Discord Community Guidelines](https://discordapp.com/guidelines).", - - "Don't intentionally make other people uncomfortable - if someone asks you to stop " - "discussing something, you should stop.", - - "Be patient both with users asking questions, and the users answering them.", - - "We will not help you with anything that might break a law or the terms of service " - "of any other community, site, service, or otherwise - No piracy, brute-forcing, " - "captcha circumvention, sneaker bots, or anything else of that nature.", - - "Listen to and respect the staff members - we're here to help, but we're all human " - "beings.", - - "All discussion should be kept within the relevant channels for the subject - See the " - "[channels page](https://pythondiscord.com/about/channels) for more information.", - - "This is an English-speaking server, so please speak English to the best of your " - "ability - [Google Translate](https://translate.google.com/) should be fine if you're " - "not sure.", - - "Keep all discussions safe for work - No gore, nudity, sexual soliciting, references " - "to suicide, or anything else of that nature", - - "We do not allow advertisements for communities (including other Discord servers) or " - "commercial projects - Contact us directly if you want to discuss a partnership!" - ) - self.default_desc = ("The rules and guidelines that apply to this community can be found on" - " our [rules page](https://pythondiscord.com/about/rules). We expect" - " all members of the community to have read and understood these." - ) - self.title_link = 'https://pythondiscord.com/about/rules' - - @command(aliases=['r', 'rule'], name='rules') - @redirect_output(destination_channel=Channels.bot, bypass_roles=STAFF_ROLES) - async def rules_command(self, ctx: Context, *, rules: Optional[str] = None): - """ - Provides a link to the `rules` endpoint of the website, or displays - specific rules, if they are requested. - - **`ctx`:** The Discord message context - **`rules`:** The rules a user wants to get. - """ - rules_embed = Embed(title='Rules', color=Colour.blurple()) - - if not rules: - # Rules were not submitted. Return the default description. - rules_embed.description = self.default_desc - rules_embed.url = 'https://pythondiscord.com/about/rules' - return await ctx.send(embed=rules_embed) - - # Split the rules input by slash, comma or space - # Returns a list of ints if they're in range of rules index - rules_to_get = [] - split_rules = re.split(r'[/, ]', rules) - for item in split_rules: - if not item.isdigit(): - if not item: - continue - rule_match = re.search(r'\d?\d[:|-]1?\d', item) - if rule_match: - a, b = sorted([int(x)-1 for x in re.split(r'[:-]', rule_match.group())]) - rules_to_get.extend(range(a, b+1)) - else: - rules_to_get.append(int(item)-1) - final_rules = [ - f'**{i+1}.** {self.rules[i]}' for i in sorted(rules_to_get) if i < len(self.rules) - ] - - if not final_rules: - # No valid rules in rules input. Return the default description. - rules_embed.description = self.default_desc - return await ctx.send(embed=rules_embed) - await LinePaginator.paginate( - final_rules, ctx, rules_embed, - max_lines=3, url=self.title_link - ) - - -def setup(bot): - bot.add_cog(Rules(bot)) diff --git a/bot/cogs/security.py b/bot/cogs/security.py index f4a843fbf..316b33d6b 100644 --- a/bot/cogs/security.py +++ b/bot/cogs/security.py @@ -1,27 +1,30 @@ import logging -from discord.ext.commands import Bot, Context +from discord.ext.commands import Bot, Cog, Context, NoPrivateMessage log = logging.getLogger(__name__) -class Security: - """ - Security-related helpers - """ +class Security(Cog): + """Security-related helpers.""" def __init__(self, bot: Bot): self.bot = bot self.bot.check(self.check_not_bot) # Global commands check - no bots can run any commands at all self.bot.check(self.check_on_guild) # Global commands check - commands can't be run in a DM - def check_not_bot(self, ctx: Context): + def check_not_bot(self, ctx: Context) -> bool: + """Check if the context is a bot user.""" return not ctx.author.bot - def check_on_guild(self, ctx: Context): - return ctx.guild is not None + def check_on_guild(self, ctx: Context) -> bool: + """Check if the context is in a guild.""" + if ctx.guild is None: + raise NoPrivateMessage("This command cannot be used in private messages.") + return True -def setup(bot): +def setup(bot: Bot) -> None: + """Security cog load.""" bot.add_cog(Security(bot)) log.info("Cog loaded: Security") diff --git a/bot/cogs/site.py b/bot/cogs/site.py index e5fd645fb..4a423faa9 100644 --- a/bot/cogs/site.py +++ b/bot/cogs/site.py @@ -1,31 +1,31 @@ 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 URLs +from bot.constants import Channels, STAFF_ROLES, URLs +from bot.decorators import redirect_output +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): self.bot = bot @group(name="site", aliases=("s",), invoke_without_command=True) - async def site_group(self, ctx): + async def site_group(self, ctx: Context) -> None: """Commands for getting info about our website.""" - await ctx.invoke(self.bot.get_command("help"), "site") @site_group.command(name="home", aliases=("about",)) - async def site_main(self, ctx: Context): + async def site_main(self, ctx: Context) -> None: """Info about the website itself.""" - url = f"{URLs.site_schema}{URLs.site}/" embed = Embed(title="Python Discord website") @@ -41,29 +41,30 @@ class Site: await ctx.send(embed=embed) @site_group.command(name="resources") - async def site_resources(self, ctx: Context): + async def site_resources(self, ctx: Context) -> None: """Info about the site's Resources page.""" + learning_url = f"{PAGES_URL}/resources" + tools_url = f"{PAGES_URL}/tools" - url = f"{INFO_URL}/resources" - - 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) @site_group.command(name="help") - async def site_help(self, ctx: Context): + async def site_help(self, ctx: Context) -> None: """Info about the site's Getting Help page.""" + url = f"{PAGES_URL}/asking-good-questions" - url = f"{INFO_URL}/help" - - embed = Embed(title="Getting Help") + embed = Embed(title="Asking Good Questions") embed.set_footer(text=url) embed.colour = Colour.blurple() embed.description = ( @@ -75,10 +76,9 @@ class Site: await ctx.send(embed=embed) @site_group.command(name="faq") - async def site_faq(self, ctx: Context): + 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) @@ -92,7 +92,42 @@ class Site: await ctx.send(embed=embed) + @site_group.command(aliases=['r', 'rule'], name='rules') + @redirect_output(destination_channel=Channels.bot, bypass_roles=STAFF_ROLES) + async def site_rules(self, ctx: Context, *rules: int) -> None: + """Provides a link to all rules or, if specified, displays specific rule(s).""" + rules_embed = Embed(title='Rules', color=Colour.blurple()) + rules_embed.url = f"{PAGES_URL}/rules" + + 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" + f" our [rules page]({PAGES_URL}/rules). We expect" + " all members of the community to have read and understood these." + ) + + await ctx.send(embed=rules_embed) + return + + full_rules = await self.bot.api_client.get('rules', params={'link_format': 'md'}) + invalid_indices = tuple( + pick + for pick in rules + if pick < 0 or pick >= len(full_rules) + ) + + if invalid_indices: + indices = ', '.join(map(str, invalid_indices)) + await ctx.send(f":x: Invalid rule indices {indices}") + return + + final_rules = tuple(f"**{pick}.** {full_rules[pick]}" for pick in rules) + + await LinePaginator.paginate(final_rules, ctx, rules_embed, max_lines=3) + -def setup(bot): +def setup(bot: Bot) -> None: + """Site cog load.""" bot.add_cog(Site(bot)) log.info("Cog loaded: Site") diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index cc18c0041..5accbdb5e 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -1,39 +1,19 @@ 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.cogs.rmq import RMQ -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 log = logging.getLogger(__name__) -RMQ_ARGS = { - "durable": False, - "arguments": {"x-message-ttl": 5000}, - "auto_delete": True -} - -CODE_TEMPLATE = """ -venv_file = "/snekbox/.venv/bin/activate_this.py" -exec(open(venv_file).read(), dict(__file__=venv_file)) - -try: -{CODE} -except: - import traceback - print(traceback.format_exc()) -""" - ESCAPE_REGEX = re.compile("[`\u202E\u200B]{3,}") FORMATTED_CODE_REGEX = re.compile( r"^\s*" # any leading whitespace from the beginning of the string @@ -53,42 +33,45 @@ RAW_CODE_REGEX = re.compile( re.DOTALL # "." also matches newlines ) +MAX_PASTE_LEN = 1000 -class Snekbox: - """ - Safe evaluation using Snekbox - """ + +class Snekbox(Cog): + """Safe evaluation of Python code using Snekbox.""" def __init__(self, bot: Bot): self.bot = bot self.jobs = {} - @property - def rmq(self) -> RMQ: - return self.bot.get_cog("RMQ") + async def post_eval(self, code: str) -> dict: + """Send a POST request to the Snekbox API to evaluate code and return the results.""" + url = URLs.snekbox_eval_api + data = {"input": code} + async with self.bot.http_session.post(url, json=data, raise_for_status=True) as resp: + return await resp.json() - @command(name='eval', aliases=('e',)) - @guild_only() - @in_channel(Channels.bot, bypass_roles=STAFF_ROLES) - async def eval_command(self, ctx: Context, *, code: str = None): - """ - Run some code. get the result back. We've done our best to make this safe, but do let us know if you - manage to find an issue with it! - - This command supports multiple lines of code, including code wrapped inside a formatted code block. - """ - - if ctx.author.id in self.jobs: - await ctx.send(f"{ctx.author.mention} You've already got a job running - please wait for it to finish!") - return - - if not code: # None or empty string - return await ctx.invoke(self.bot.get_command("help"), "eval") + async def upload_output(self, output: str) -> Optional[str]: + """Upload the eval output to a paste service and return a URL to it if successful.""" + log.trace("Uploading full output to paste service...") - log.info(f"Received code from {ctx.author.name}#{ctx.author.discriminator} for evaluation:\n{code}") - self.jobs[ctx.author.id] = datetime.datetime.now() + if len(output) > MAX_PASTE_LEN: + log.info("Full output is too long to upload") + return "too long to upload" - # Strip whitespace and inline or block code markdown and extract the code and some formatting info + url = URLs.paste_service.format(key="documents") + try: + async with self.bot.http_session.post(url, data=output, raise_for_status=True) as resp: + data = await resp.json() + + if "key" in data: + return URLs.paste_service.format(key=data["key"]) + except Exception: + # 400 (Bad Request) means there are too many characters + log.exception("Failed to upload full output to paste service!") + + @staticmethod + def prepare_input(code: str) -> str: + """Extract code from the Markdown, format it, and insert it into the code template.""" match = FORMATTED_CODE_REGEX.fullmatch(code) if match: code, block, lang, delim = match.group("code", "block", "lang", "delim") @@ -100,112 +83,145 @@ class Snekbox: log.trace(f"Extracted {info} for evaluation:\n{code}") else: code = textwrap.dedent(RAW_CODE_REGEX.fullmatch(code).group("code")) - log.trace(f"Eval message contains not or badly formatted code, stripping whitespace only:\n{code}") + log.trace( + f"Eval message contains unformatted or badly formatted code, " + f"stripping whitespace only:\n{code}" + ) - try: - stripped_lines = [ln.strip() for ln in code.split('\n')] - if all(line.startswith('#') for line in stripped_lines): - return await ctx.send( - f"{ctx.author.mention} Your eval job has completed.\n\n```[No output]```" - ) + return code + + @staticmethod + def get_results_message(results: dict) -> Tuple[str, str]: + """Return a user-friendly message and error corresponding to the process's return code.""" + stdout, returncode = results["stdout"], results["returncode"] + msg = f"Your eval job has completed with return code {returncode}" + error = "" + + if returncode is None: + msg = "Your eval job has failed" + error = stdout.strip() + elif returncode == 128 + Signals.SIGKILL: + msg = "Your eval job timed out or ran out of memory" + elif returncode == 255: + msg = "Your eval job has failed" + error = "A fatal NsJail error occurred" + else: + # Try to append signal's name if one exists + try: + name = Signals(returncode - 128).name + msg = f"{msg} ({name})" + except ValueError: + pass - code = textwrap.indent(code, " ") - code = CODE_TEMPLATE.replace("{CODE}", code) + return msg, error - await self.rmq.send_json( - "input", - snekid=str(ctx.author.id), message=code - ) + async def format_output(self, output: str) -> Tuple[str, Optional[str]]: + """ + Format the output and return a tuple of the formatted output and a URL to the full output. - async with ctx.typing(): - message = await self.rmq.consume(str(ctx.author.id), **RMQ_ARGS) - paste_link = None + Prepend each line with a line number. Truncate if there are over 10 lines or 1000 characters + and upload the full output to a paste service. + """ + log.trace("Formatting output...") - if isinstance(message, str): - output = str.strip(" \n") - else: - output = message.body.decode().strip(" \n") + output = output.strip(" \n") + original_output = output # To be uploaded to a pasting service if needed + paste_link = None - if "<@" in output: - output = output.replace("<@", "<@\u200B") # Zero-width space + if "<@" in output: + output = output.replace("<@", "<@\u200B") # Zero-width space - if "<!@" in output: - output = output.replace("<!@", "<!@\u200B") # Zero-width space + if "<!@" in output: + output = output.replace("<!@", "<!@\u200B") # Zero-width space - if ESCAPE_REGEX.findall(output): - output = "Code block escape attempt detected; will not output result" - else: - # the original output, to send to a pasting service if needed - full_output = output - truncated = False - if output.count("\n") > 0: - output = [f"{i:03d} | {line}" for i, line in enumerate(output.split("\n"), start=1)] - output = "\n".join(output) - - if output.count("\n") > 10: - output = "\n".join(output.split("\n")[:10]) - - if len(output) >= 1000: - output = f"{output[:1000]}\n... (truncated - too long, too many lines)" - else: - output = f"{output}\n... (truncated - too many lines)" - truncated = True - - elif len(output) >= 1000: - output = f"{output[:1000]}\n... (truncated - too long)" - truncated = True - - if truncated: - try: - response = await self.bot.http_session.post( - URLs.paste_service.format(key="documents"), - data=full_output - ) - data = await response.json() - if "key" in data: - paste_link = URLs.paste_service.format(key=data["key"]) - except Exception: - log.exception("Failed to upload full output to paste service!") - - if output.strip(): - if paste_link: - msg = f"{ctx.author.mention} Your eval job has completed.\n\n```py\n{output}\n```" \ - f"\nFull output: {paste_link}" - else: - msg = f"{ctx.author.mention} Your eval job has completed.\n\n```py\n{output}\n```" - - response = await ctx.send(msg) - self.bot.loop.create_task(wait_for_deletion(response, user_ids=(ctx.author.id,), client=ctx.bot)) + if ESCAPE_REGEX.findall(output): + return "Code block escape attempt detected; will not output result", paste_link - else: - await ctx.send( - f"{ctx.author.mention} Your eval job has completed.\n\n```[No output]```" - ) - finally: - del self.jobs[ctx.author.id] + truncated = False + lines = output.count("\n") - @eval_command.error - async def eval_command_error(self, ctx: Context, error: CommandError): - embed = Embed(colour=Colour.red()) + if lines > 0: + output = output.split("\n")[:10] # Only first 10 cause the rest is truncated anyway + output = (f"{i:03d} | {line}" for i, line in enumerate(output, 1)) + output = "\n".join(output) - 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) + if lines > 10: + truncated = True + if len(output) >= 1000: + output = f"{output[:1000]}\n... (truncated - too long, too many lines)" + else: + output = f"{output}\n... (truncated - too many lines)" + elif len(output) >= 1000: + truncated = True + output = f"{output[:1000]}\n... (truncated - too long)" - elif isinstance(error, InChannelCheckFailure): - embed.title = random.choice(NEGATIVE_REPLIES) - embed.description = str(error) - await ctx.send(embed=embed) + if truncated: + paste_link = await self.upload_output(original_output) - 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) + output = output.strip() + if not output: + output = "[No output]" + + return output, paste_link + + @command(name="eval", aliases=("e",)) + @guild_only() + @in_channel(Channels.bot, bypass_roles=STAFF_ROLES) + async def eval_command(self, ctx: Context, *, code: str = None) -> None: + """ + Run Python code and get the results. + + This command supports multiple lines of code, including code wrapped inside a formatted code + block. We've done our best to make this safe, but do let us know if you manage to find an + issue with it! + """ + if ctx.author.id in self.jobs: + await ctx.send( + f"{ctx.author.mention} You've already got a job running - " + f"please wait for it to finish!" + ) + return + + if not code: # None or empty string + await ctx.invoke(self.bot.get_command("help"), "eval") + return + + log.info( + f"Received code from {ctx.author.name}#{ctx.author.discriminator} " + f"for evaluation:\n{code}" + ) + + self.jobs[ctx.author.id] = datetime.datetime.now() + code = self.prepare_input(code) + + try: + async with ctx.typing(): + results = await self.post_eval(code) + msg, error = self.get_results_message(results) + + if error: + output, paste_link = error, None + else: + output, paste_link = await self.format_output(results["stdout"]) + + msg = f"{ctx.author.mention} {msg}.\n\n```py\n{output}\n```" + if paste_link: + msg = f"{msg}\nFull output: {paste_link}" + + response = await ctx.send(msg) + self.bot.loop.create_task( + wait_for_deletion(response, user_ids=(ctx.author.id,), client=ctx.bot) + ) + + log.info( + f"{ctx.author.name}#{ctx.author.discriminator}'s job had a return code of " + f"{results['returncode']}" + ) + finally: + del self.jobs[ctx.author.id] -def setup(bot): +def setup(bot: Bot) -> None: + """Snekbox cog load.""" bot.add_cog(Snekbox(bot)) log.info("Cog loaded: Snekbox") diff --git a/bot/cogs/superstarify.py b/bot/cogs/superstarify.py deleted file mode 100644 index f46f62552..000000000 --- a/bot/cogs/superstarify.py +++ /dev/null @@ -1,285 +0,0 @@ -import logging -import random - -from discord import Colour, Embed, Member -from discord.errors import Forbidden -from discord.ext.commands import Bot, Context, command - -from bot.cogs.moderation import Moderation -from bot.cogs.modlog import ModLog -from bot.constants import ( - Icons, Keys, - MODERATION_ROLES, NEGATIVE_REPLIES, - POSITIVE_REPLIES, URLs -) -from bot.decorators import with_role - -log = logging.getLogger(__name__) -NICKNAME_POLICY_URL = "https://pythondiscord.com/about/rules#nickname-policy" - - -class Superstarify: - """ - A set of commands to moderate terrible nicknames. - """ - - def __init__(self, bot: Bot): - self.bot = bot - self.headers = {"X-API-KEY": Keys.site_api} - - @property - def moderation(self) -> Moderation: - return self.bot.get_cog("Moderation") - - @property - def modlog(self) -> ModLog: - return self.bot.get_cog("ModLog") - - async def on_member_update(self, before: Member, after: Member): - """ - This event will trigger when someone changes their name. - At this point we will look up the user in our database and check - whether they are allowed to change their names, or if they are in - superstar-prison. If they are not allowed, we will change it back. - """ - - if before.display_name == after.display_name: - return # User didn't change their nickname. Abort! - - log.debug( - f"{before.display_name} is trying to change their nickname to {after.display_name}. " - "Checking if the user is in superstar-prison..." - ) - - response = await self.bot.http_session.get( - URLs.site_superstarify_api, - headers=self.headers, - params={"user_id": str(before.id)} - ) - - response = await response.json() - - if response and response.get("end_timestamp") and not response.get("error_code"): - if after.display_name == response.get("forced_nick"): - return # Nick change was triggered by this event. Ignore. - - log.debug( - f"{after.display_name} is currently in superstar-prison. " - f"Changing the nick back to {before.display_name}." - ) - await after.edit(nick=response.get("forced_nick")) - try: - await after.send( - "You have tried to change your nickname on the **Python Discord** server " - f"from **{before.display_name}** to **{after.display_name}**, but as you " - "are currently in superstar-prison, you do not have permission to do so. " - "You will be allowed to change your nickname again at the following time:\n\n" - f"**{response.get('end_timestamp')}**." - ) - except Forbidden: - log.warning( - "The user tried to change their nickname while in superstar-prison. " - "This led to the bot trying to DM the user to let them know they cannot do that, " - "but the user had either blocked the bot or disabled DMs, so it was not possible " - "to DM them, and a discord.errors.Forbidden error was incurred." - ) - - async def on_member_join(self, member: Member): - """ - This event will trigger when someone (re)joins the server. - At this point we will look up the user in our database and check - whether they are in superstar-prison. If so, we will change their name - back to the forced nickname. - """ - - response = await self.bot.http_session.get( - URLs.site_superstarify_api, - headers=self.headers, - params={"user_id": str(member.id)} - ) - - response = await response.json() - - if response and response.get("end_timestamp") and not response.get("error_code"): - forced_nick = response.get("forced_nick") - end_timestamp = response.get("end_timestamp") - log.debug( - f"{member.name} rejoined but is currently in superstar-prison. " - f"Changing the nick back to {forced_nick}." - ) - - await member.edit(nick=forced_nick) - try: - await member.send( - "You have left and rejoined the **Python Discord** server, effectively resetting " - f"your nickname from **{forced_nick}** to **{member.name}**, " - "but as you are currently in superstar-prison, you do not have permission to do so. " - "Therefore your nickname was automatically changed back. You will be allowed to " - "change your nickname again at the following time:\n\n" - f"**{end_timestamp}**." - ) - except Forbidden: - log.warning( - "The user left and rejoined the server while in superstar-prison. " - "This led to the bot trying to DM the user to let them know their name was restored, " - "but the user had either blocked the bot or disabled DMs, so it was not possible " - "to DM them, and a discord.errors.Forbidden error was incurred." - ) - - # Log to the mod_log channel - log.trace("Logging to the #mod-log channel. This could fail because of channel permissions.") - mod_log_message = ( - f"**{member.name}#{member.discriminator}** (`{member.id}`)\n\n" - f"Superstarified member potentially tried to escape the prison.\n" - f"Restored enforced nickname: `{forced_nick}`\n" - f"Superstardom ends: **{end_timestamp}**" - ) - await self.modlog.send_log_message( - icon_url=Icons.user_update, - colour=Colour.gold(), - title="Superstar member rejoined server", - text=mod_log_message, - thumbnail=member.avatar_url_as(static_format="png") - ) - - @command(name='superstarify', aliases=('force_nick', 'star')) - @with_role(*MODERATION_ROLES) - async def superstarify(self, ctx: Context, member: Member, duration: str, *, forced_nick: str = None): - """ - This command will force a random superstar name (like Taylor Swift) to be the user's - nickname for a specified duration. If a forced_nick is provided, it will use that instead. - - :param ctx: Discord message context - :param ta: - If provided, this function shows data for that specific tag. - If not provided, this function shows the caller a list of all tags. - """ - - log.debug( - f"Attempting to superstarify {member.display_name} for {duration}. " - f"forced_nick is set to {forced_nick}." - ) - - embed = Embed() - embed.colour = Colour.blurple() - - params = { - "user_id": str(member.id), - "duration": duration - } - - if forced_nick: - params["forced_nick"] = forced_nick - - response = await self.bot.http_session.post( - URLs.site_superstarify_api, - headers=self.headers, - json=params - ) - - response = await response.json() - - if "error_message" in response: - log.warning( - "Encountered the following error when trying to superstarify the user:\n" - f"{response.get('error_message')}" - ) - embed.colour = Colour.red() - embed.title = random.choice(NEGATIVE_REPLIES) - embed.description = response.get("error_message") - return await ctx.send(embed=embed) - - else: - forced_nick = response.get('forced_nick') - end_time = response.get("end_timestamp") - image_url = response.get("image_url") - old_nick = member.display_name - - embed.title = "Congratulations!" - embed.description = ( - f"Your previous nickname, **{old_nick}**, was so bad that we have decided to change it. " - f"Your new nickname will be **{forced_nick}**.\n\n" - f"You will be unable to change your nickname until \n**{end_time}**.\n\n" - "If you're confused by this, please read our " - f"[official nickname policy]({NICKNAME_POLICY_URL})." - ) - embed.set_image(url=image_url) - - # Log to the mod_log channel - log.trace("Logging to the #mod-log channel. This could fail because of channel permissions.") - mod_log_message = ( - f"**{member.name}#{member.discriminator}** (`{member.id}`)\n\n" - f"Superstarified by **{ctx.author.name}**\n" - f"Old nickname: `{old_nick}`\n" - f"New nickname: `{forced_nick}`\n" - f"Superstardom ends: **{end_time}**" - ) - await self.modlog.send_log_message( - icon_url=Icons.user_update, - colour=Colour.gold(), - title="Member Achieved Superstardom", - text=mod_log_message, - thumbnail=member.avatar_url_as(static_format="png") - ) - - await self.moderation.notify_infraction( - user=member, - infr_type="Superstarify", - duration=duration, - reason=f"Your nickname didn't comply with our [nickname policy]({NICKNAME_POLICY_URL})." - ) - - # Change the nick and return the embed - log.debug("Changing the users nickname and sending the embed.") - await member.edit(nick=forced_nick) - await ctx.send(embed=embed) - - @command(name='unsuperstarify', aliases=('release_nick', 'unstar')) - @with_role(*MODERATION_ROLES) - async def unsuperstarify(self, ctx: Context, member: Member): - """ - This command will remove the entry from our database, allowing the user - to once again change their nickname. - - :param ctx: Discord message context - :param member: The member to unsuperstarify - """ - - log.debug(f"Attempting to unsuperstarify the following user: {member.display_name}") - - embed = Embed() - embed.colour = Colour.blurple() - - response = await self.bot.http_session.delete( - URLs.site_superstarify_api, - headers=self.headers, - json={"user_id": str(member.id)} - ) - - response = await response.json() - embed.description = "User has been released from superstar-prison." - embed.title = random.choice(POSITIVE_REPLIES) - - if "error_message" in response: - embed.colour = Colour.red() - embed.title = random.choice(NEGATIVE_REPLIES) - embed.description = response.get("error_message") - log.warning( - f"Error encountered when trying to unsuperstarify {member.display_name}:\n" - f"{response}" - ) - - else: - await self.moderation.notify_pardon( - user=member, - title="You are no longer superstarified.", - content="You may now change your nickname on the server." - ) - - log.debug(f"{member.display_name} was successfully released from superstar-prison.") - await ctx.send(embed=embed) - - -def setup(bot): - bot.add_cog(Superstarify(bot)) - log.info("Cog loaded: Superstarify") diff --git a/bot/cogs/superstarify/__init__.py b/bot/cogs/superstarify/__init__.py new file mode 100644 index 000000000..f7d6a269d --- /dev/null +++ b/bot/cogs/superstarify/__init__.py @@ -0,0 +1,269 @@ +import logging +import random +from datetime import datetime + +from discord import Colour, Embed, Member +from discord.errors import Forbidden +from discord.ext.commands import Bot, Cog, Context, command + +from bot.cogs.moderation import Moderation +from bot.cogs.modlog import ModLog +from bot.cogs.superstarify.stars import get_nick +from bot.constants import Icons, MODERATION_ROLES, POSITIVE_REPLIES +from bot.converters import ExpirationDate +from bot.decorators import with_role +from bot.utils.moderation import post_infraction + +log = logging.getLogger(__name__) +NICKNAME_POLICY_URL = "https://pythondiscord.com/pages/rules/#wiki-toc-nickname-policy" + + +class Superstarify(Cog): + """A set of commands to moderate terrible nicknames.""" + + def __init__(self, bot: Bot): + self.bot = bot + + @property + def moderation(self) -> Moderation: + """Get currently loaded Moderation cog instance.""" + return self.bot.get_cog("Moderation") + + @property + def modlog(self) -> ModLog: + """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. + + At this point we will look up the user in our database and check whether they are allowed to + change their names, or if they are in superstar-prison. If they are not allowed, we will + change it back. + """ + if before.display_name == after.display_name: + return # User didn't change their nickname. Abort! + + log.trace( + f"{before.display_name} is trying to change their nickname to {after.display_name}. " + "Checking if the user is in superstar-prison..." + ) + + active_superstarifies = await self.bot.api_client.get( + 'bot/infractions', + params={ + 'active': 'true', + 'type': 'superstar', + 'user__id': str(before.id) + } + ) + + if active_superstarifies: + [infraction] = active_superstarifies + forced_nick = get_nick(infraction['id'], before.id) + if after.display_name == forced_nick: + return # Nick change was triggered by this event. Ignore. + + log.info( + f"{after.display_name} is currently in superstar-prison. " + f"Changing the nick back to {before.display_name}." + ) + await after.edit(nick=forced_nick) + end_timestamp_human = ( + datetime.fromisoformat(infraction['expires_at'][:-1]) + .strftime('%c') + ) + + try: + await after.send( + "You have tried to change your nickname on the **Python Discord** server " + f"from **{before.display_name}** to **{after.display_name}**, but as you " + "are currently in superstar-prison, you do not have permission to do so. " + "You will be allowed to change your nickname again at the following time:\n\n" + f"**{end_timestamp_human}**." + ) + except Forbidden: + log.warning( + "The user tried to change their nickname while in superstar-prison. " + "This led to the bot trying to DM the user to let them know they cannot do that, " + "but the user had either blocked the bot or disabled DMs, so it was not possible " + "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. + + At this point we will look up the user in our database and check whether they are in + superstar-prison. If so, we will change their name back to the forced nickname. + """ + active_superstarifies = await self.bot.api_client.get( + 'bot/infractions', + params={ + 'active': 'true', + 'type': 'superstar', + 'user__id': member.id + } + ) + + if active_superstarifies: + [infraction] = active_superstarifies + forced_nick = get_nick(infraction['id'], member.id) + await member.edit(nick=forced_nick) + end_timestamp_human = ( + datetime.fromisoformat(infraction['expires_at'][:-1]).strftime('%c') + ) + + try: + await member.send( + "You have left and rejoined the **Python Discord** server, effectively resetting " + f"your nickname from **{forced_nick}** to **{member.name}**, " + "but as you are currently in superstar-prison, you do not have permission to do so. " + "Therefore your nickname was automatically changed back. You will be allowed to " + "change your nickname again at the following time:\n\n" + f"**{end_timestamp_human}**." + ) + except Forbidden: + log.warning( + "The user left and rejoined the server while in superstar-prison. " + "This led to the bot trying to DM the user to let them know their name was restored, " + "but the user had either blocked the bot or disabled DMs, so it was not possible " + "to DM them, and a discord.errors.Forbidden error was incurred." + ) + + # Log to the mod_log channel + log.trace("Logging to the #mod-log channel. This could fail because of channel permissions.") + mod_log_message = ( + f"**{member.name}#{member.discriminator}** (`{member.id}`)\n\n" + f"Superstarified member potentially tried to escape the prison.\n" + f"Restored enforced nickname: `{forced_nick}`\n" + f"Superstardom ends: **{end_timestamp_human}**" + ) + await self.modlog.send_log_message( + icon_url=Icons.user_update, + colour=Colour.gold(), + title="Superstar member rejoined server", + text=mod_log_message, + thumbnail=member.avatar_url_as(static_format="png") + ) + + @command(name='superstarify', aliases=('force_nick', 'star')) + @with_role(*MODERATION_ROLES) + async def superstarify( + self, ctx: Context, member: Member, expiration: ExpirationDate, reason: str = None + ) -> None: + """ + Force a random superstar name (like Taylor Swift) to be the user's nickname for a specified duration. + + An optional reason can be provided. + + If no reason is given, the original name will be shown in a generated reason. + """ + active_superstarifies = await self.bot.api_client.get( + 'bot/infractions', + params={ + 'active': 'true', + 'type': 'superstar', + 'user__id': str(member.id) + } + ) + if active_superstarifies: + await ctx.send( + ":x: According to my records, this user is already superstarified. " + f"See infraction **#{active_superstarifies[0]['id']}**." + ) + return + + infraction = await post_infraction( + ctx, member, + type='superstar', reason=reason or ('old nick: ' + member.display_name), + expires_at=expiration + ) + forced_nick = get_nick(infraction['id'], member.id) + + embed = Embed() + embed.title = "Congratulations!" + embed.description = ( + f"Your previous nickname, **{member.display_name}**, was so bad that we have decided to change it. " + f"Your new nickname will be **{forced_nick}**.\n\n" + f"You will be unable to change your nickname until \n**{expiration}**.\n\n" + "If you're confused by this, please read our " + f"[official nickname policy]({NICKNAME_POLICY_URL})." + ) + + # Log to the mod_log channel + log.trace("Logging to the #mod-log channel. This could fail because of channel permissions.") + mod_log_message = ( + f"**{member.name}#{member.discriminator}** (`{member.id}`)\n\n" + f"Superstarified by **{ctx.author.name}**\n" + f"Old nickname: `{member.display_name}`\n" + f"New nickname: `{forced_nick}`\n" + f"Superstardom ends: **{expiration}**" + ) + await self.modlog.send_log_message( + icon_url=Icons.user_update, + colour=Colour.gold(), + title="Member Achieved Superstardom", + text=mod_log_message, + thumbnail=member.avatar_url_as(static_format="png") + ) + + await self.moderation.notify_infraction( + user=member, + infr_type="Superstarify", + expires_at=expiration, + reason=f"Your nickname didn't comply with our [nickname policy]({NICKNAME_POLICY_URL})." + ) + + # Change the nick and return the embed + log.trace("Changing the users nickname and sending the embed.") + await member.edit(nick=forced_nick) + await ctx.send(embed=embed) + + @command(name='unsuperstarify', aliases=('release_nick', 'unstar')) + @with_role(*MODERATION_ROLES) + async def unsuperstarify(self, ctx: Context, member: Member) -> None: + """Remove the superstarify entry from our database, allowing the user to change their nickname.""" + log.debug(f"Attempting to unsuperstarify the following user: {member.display_name}") + + embed = Embed() + embed.colour = Colour.blurple() + + active_superstarifies = await self.bot.api_client.get( + 'bot/infractions', + params={ + 'active': 'true', + 'type': 'superstar', + 'user__id': str(member.id) + } + ) + if not active_superstarifies: + await ctx.send(":x: There is no active superstarify infraction for this user.") + return + + [infraction] = active_superstarifies + await self.bot.api_client.patch( + 'bot/infractions/' + str(infraction['id']), + json={'active': False} + ) + + embed = Embed() + embed.description = "User has been released from superstar-prison." + embed.title = random.choice(POSITIVE_REPLIES) + + await self.moderation.notify_pardon( + user=member, + title="You are no longer superstarified.", + content="You may now change your nickname on the server." + ) + log.trace(f"{member.display_name} was successfully released from superstar-prison.") + await ctx.send(embed=embed) + + +def setup(bot: Bot) -> None: + """Superstarify cog load.""" + bot.add_cog(Superstarify(bot)) + log.info("Cog loaded: Superstarify") diff --git a/bot/cogs/superstarify/stars.py b/bot/cogs/superstarify/stars.py new file mode 100644 index 000000000..dbac86770 --- /dev/null +++ b/bot/cogs/superstarify/stars.py @@ -0,0 +1,87 @@ +import random + + +STAR_NAMES = ( + "Adele", + "Aerosmith", + "Aretha Franklin", + "Ayumi Hamasaki", + "B'z", + "Barbra Streisand", + "Barry Manilow", + "Barry White", + "Beyonce", + "Billy Joel", + "Bob Dylan", + "Bob Marley", + "Bob Seger", + "Bon Jovi", + "Britney Spears", + "Bruce Springsteen", + "Bruno Mars", + "Bryan Adams", + "Celine Dion", + "Cher", + "Christina Aguilera", + "David Bowie", + "Donna Summer", + "Drake", + "Ed Sheeran", + "Elton John", + "Elvis Presley", + "Eminem", + "Enya", + "Flo Rida", + "Frank Sinatra", + "Garth Brooks", + "George Michael", + "George Strait", + "James Taylor", + "Janet Jackson", + "Jay-Z", + "Johnny Cash", + "Johnny Hallyday", + "Julio Iglesias", + "Justin Bieber", + "Justin Timberlake", + "Kanye West", + "Katy Perry", + "Kenny G", + "Kenny Rogers", + "Lady Gaga", + "Lil Wayne", + "Linda Ronstadt", + "Lionel Richie", + "Madonna", + "Mariah Carey", + "Meat Loaf", + "Michael Jackson", + "Neil Diamond", + "Nicki Minaj", + "Olivia Newton-John", + "Paul McCartney", + "Phil Collins", + "Pink", + "Prince", + "Reba McEntire", + "Rihanna", + "Robbie Williams", + "Rod Stewart", + "Santana", + "Shania Twain", + "Stevie Wonder", + "Taylor Swift", + "Tim McGraw", + "Tina Turner", + "Tom Petty", + "Tupac Shakur", + "Usher", + "Van Halen", + "Whitney Houston", +) + + +def get_nick(infraction_id: int, member_id: int) -> str: + """Randomly select a nickname from the Superstarify nickname list.""" + rng = random.Random(str(infraction_id) + str(member_id)) + return rng.choice(STAR_NAMES) diff --git a/bot/cogs/sync/__init__.py b/bot/cogs/sync/__init__.py new file mode 100644 index 000000000..d4565f848 --- /dev/null +++ b/bot/cogs/sync/__init__.py @@ -0,0 +1,13 @@ +import logging + +from discord.ext.commands import Bot + +from .cog import Sync + +log = logging.getLogger(__name__) + + +def setup(bot: Bot) -> None: + """Sync cog load.""" + bot.add_cog(Sync(bot)) + log.info("Cog loaded: Sync") diff --git a/bot/cogs/sync/cog.py b/bot/cogs/sync/cog.py new file mode 100644 index 000000000..b75fb26cd --- /dev/null +++ b/bot/cogs/sync/cog.py @@ -0,0 +1,200 @@ +import logging +from typing import Callable, Iterable + +from discord import Guild, Member, Role +from discord.ext import commands +from discord.ext.commands import Bot, Cog, Context + +from bot import constants +from bot.api import ResponseCodeError +from bot.cogs.sync import syncers + +log = logging.getLogger(__name__) + + +class Sync(Cog): + """Captures relevant events and sends them to the site.""" + + # The server to synchronize events on. + # Note that setting this wrongly will result in things getting deleted + # that possibly shouldn't be. + SYNC_SERVER_ID = constants.Guild.id + + # An iterable of callables that are called when the bot is ready. + ON_READY_SYNCERS: Iterable[Callable[[Bot, Guild], None]] = ( + syncers.sync_roles, + syncers.sync_users + ) + + 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) + if guild is not None: + for syncer in self.ON_READY_SYNCERS: + syncer_name = syncer.__name__[5:] # drop off `sync_` + log.info("Starting `%s` syncer.", syncer_name) + total_created, total_updated, total_deleted = await syncer(self.bot, guild) + if total_deleted is None: + log.info( + f"`{syncer_name}` syncer finished, created `{total_created}`, updated `{total_updated}`." + ) + else: + log.info( + f"`{syncer_name}` syncer finished, created `{total_created}`, updated `{total_updated}`, " + 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( + 'bot/roles', + json={ + 'colour': role.colour.value, + 'id': role.id, + 'name': role.name, + 'permissions': role.permissions.value, + 'position': role.position, + } + ) + + @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 ( + before.name != after.name + or before.colour != after.colour + or before.permissions != after.permissions + or before.position != after.position + ): + await self.bot.api_client.put( + f'bot/roles/{after.id}', + json={ + 'colour': after.colour.value, + 'id': after.id, + 'name': after.name, + 'permissions': after.permissions.value, + 'position': after.position, + } + ) + + @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. + + If the joining member is a user that is already known to the database (i.e., a user that + previously left), it will update the user's information. If the user is not yet known by + the database, the user is added. + """ + packed = { + 'avatar_hash': member.avatar, + 'discriminator': int(member.discriminator), + 'id': member.id, + 'in_guild': True, + 'name': member.name, + 'roles': sorted(role.id for role in member.roles) + } + + got_error = False + + try: + # First try an update of the user to set the `in_guild` field and other + # fields that may have changed since the last time we've seen them. + await self.bot.api_client.put(f'bot/users/{member.id}', json=packed) + + except ResponseCodeError as e: + # If we didn't get 404, something else broke - propagate it up. + if e.response.status != 404: + raise + + got_error = True # yikes + + if got_error: + # If we got `404`, the user is new. Create them. + await self.bot.api_client.post('bot/users', json=packed) + + @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}', + json={ + 'avatar_hash': member.avatar, + 'discriminator': int(member.discriminator), + 'id': member.id, + 'in_guild': False, + 'name': member.name, + 'roles': sorted(role.id for role in member.roles) + } + ) + + @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 ( + before.name != after.name + or before.avatar != after.avatar + or before.discriminator != after.discriminator + or before.roles != after.roles + ): + try: + await self.bot.api_client.put( + 'bot/users/' + str(after.id), + json={ + 'avatar_hash': after.avatar, + 'discriminator': int(after.discriminator), + 'id': after.id, + 'in_guild': True, + 'name': after.name, + 'roles': sorted(role.id for role in after.roles) + } + ) + except ResponseCodeError as e: + if e.response.status != 404: + raise + + log.warning( + "Unable to update user, got 404. " + "Assuming race condition from join event." + ) + + @commands.group(name='sync') + @commands.has_permissions(administrator=True) + async def sync_group(self, ctx: Context) -> None: + """Run synchronizations between the bot and site manually.""" + + @sync_group.command(name='roles') + @commands.has_permissions(administrator=True) + async def sync_roles_command(self, ctx: Context) -> None: + """Manually synchronize the guild's roles with the roles on the site.""" + initial_response = await ctx.send("📊 Synchronizing roles.") + total_created, total_updated, total_deleted = await syncers.sync_roles(self.bot, ctx.guild) + await initial_response.edit( + content=( + f"👌 Role synchronization complete, created **{total_created}** " + f", updated **{total_created}** roles, and deleted **{total_deleted}** roles." + ) + ) + + @sync_group.command(name='users') + @commands.has_permissions(administrator=True) + async def sync_users_command(self, ctx: Context) -> None: + """Manually synchronize the guild's users with the users on the site.""" + initial_response = await ctx.send("📊 Synchronizing users.") + total_created, total_updated, total_deleted = await syncers.sync_users(self.bot, ctx.guild) + await initial_response.edit( + content=( + f"👌 User synchronization complete, created **{total_created}** " + f"and updated **{total_created}** users." + ) + ) diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py new file mode 100644 index 000000000..2cc5a66e1 --- /dev/null +++ b/bot/cogs/sync/syncers.py @@ -0,0 +1,234 @@ +from collections import namedtuple +from typing import Dict, Set, Tuple + +from discord import Guild +from discord.ext.commands import Bot + +# These objects are declared as namedtuples because tuples are hashable, +# something that we make use of when diffing site roles against guild roles. +Role = namedtuple('Role', ('id', 'name', 'colour', 'permissions', 'position')) +User = namedtuple('User', ('id', 'name', 'discriminator', 'avatar_hash', 'roles', 'in_guild')) + + +def get_roles_for_sync( + guild_roles: Set[Role], api_roles: Set[Role] +) -> Tuple[Set[Role], Set[Role], Set[Role]]: + """ + Determine which roles should be created or updated on the site. + + Arguments: + guild_roles (Set[Role]): + Roles that were found on the guild at startup. + + api_roles (Set[Role]): + Roles that were retrieved from the API at startup. + + Returns: + Tuple[Set[Role], Set[Role]. Set[Role]]: + A tuple with three elements. The first element represents + roles to be created on the site, meaning that they were + present on the cached guild but not on the API. The second + element represents roles to be updated, meaning they were + present on both the cached guild and the API but non-ID + fields have changed inbetween. The third represents roles + to be deleted on the site, meaning the roles are present on + the API but not in the cached guild. + """ + guild_role_ids = {role.id for role in guild_roles} + api_role_ids = {role.id for role in api_roles} + new_role_ids = guild_role_ids - api_role_ids + deleted_role_ids = api_role_ids - guild_role_ids + + # New roles are those which are on the cached guild but not on the + # API guild, going by the role ID. We need to send them in for creation. + roles_to_create = {role for role in guild_roles if role.id in new_role_ids} + roles_to_update = guild_roles - api_roles - roles_to_create + roles_to_delete = {role for role in api_roles if role.id in deleted_role_ids} + return roles_to_create, roles_to_update, roles_to_delete + + +async def sync_roles(bot: Bot, guild: Guild) -> Tuple[int, int, int]: + """ + Synchronize roles found on the given `guild` with the ones on the API. + + Arguments: + bot (discord.ext.commands.Bot): + The bot instance that we're running with. + + guild (discord.Guild): + The guild instance from the bot's cache + to synchronize roles with. + + Returns: + Tuple[int, int, int]: + A tuple with three integers representing how many roles were created + (element `0`) , how many roles were updated (element `1`), and how many + roles were deleted (element `2`) on the API. + """ + roles = await bot.api_client.get('bot/roles') + + # Pack API roles and guild roles into one common format, + # which is also hashable. We need hashability to be able + # to compare these easily later using sets. + api_roles = {Role(**role_dict) for role_dict in roles} + guild_roles = { + Role( + id=role.id, name=role.name, + colour=role.colour.value, permissions=role.permissions.value, + position=role.position, + ) + for role in guild.roles + } + roles_to_create, roles_to_update, roles_to_delete = get_roles_for_sync(guild_roles, api_roles) + + for role in roles_to_create: + await bot.api_client.post( + 'bot/roles', + json={ + 'id': role.id, + 'name': role.name, + 'colour': role.colour, + 'permissions': role.permissions, + 'position': role.position, + } + ) + + for role in roles_to_update: + await bot.api_client.put( + f'bot/roles/{role.id}', + json={ + 'id': role.id, + 'name': role.name, + 'colour': role.colour, + 'permissions': role.permissions, + 'position': role.position, + } + ) + + for role in roles_to_delete: + await bot.api_client.delete(f'bot/roles/{role.id}') + + return len(roles_to_create), len(roles_to_update), len(roles_to_delete) + + +def get_users_for_sync( + guild_users: Dict[int, User], api_users: Dict[int, User] +) -> Tuple[Set[User], Set[User]]: + """ + Determine which users should be created or updated on the website. + + Arguments: + guild_users (Dict[int, User]): + A mapping of user IDs to user data, populated from the + guild cached on the running bot instance. + + api_users (Dict[int, User]): + A mapping of user IDs to user data, populated from the API's + current inventory of all users. + + Returns: + Tuple[Set[User], Set[User]]: + Two user sets as a tuple. The first element represents users + to be created on the website, these are users that are present + in the cached guild data but not in the API at all, going by + their ID. The second element represents users to update. It is + populated by users which are present on both the API and the + guild, but where the attribute of a user on the API is not + equal to the attribute of the user on the guild. + """ + users_to_create = set() + users_to_update = set() + + for api_user in api_users.values(): + guild_user = guild_users.get(api_user.id) + if guild_user is not None: + if api_user != guild_user: + users_to_update.add(guild_user) + + elif api_user.in_guild: + # The user is known on the API but not the guild, and the + # API currently specifies that the user is a member of the guild. + # This means that the user has left since the last sync. + # Update the `in_guild` attribute of the user on the site + # to signify that the user left. + new_api_user = api_user._replace(in_guild=False) + users_to_update.add(new_api_user) + + new_user_ids = set(guild_users.keys()) - set(api_users.keys()) + for user_id in new_user_ids: + # The user is known on the guild but not on the API. This means + # that the user has joined since the last sync. Create it. + new_user = guild_users[user_id] + users_to_create.add(new_user) + + return users_to_create, users_to_update + + +async def sync_users(bot: Bot, guild: Guild) -> Tuple[int, int, None]: + """ + Synchronize users found in the given `guild` with the ones in the API. + + Arguments: + bot (discord.ext.commands.Bot): + The bot instance that we're running with. + + guild (discord.Guild): + The guild instance from the bot's cache + to synchronize roles with. + + Returns: + Tuple[int, int, None]: + A tuple with two integers, representing how many users were created + (element `0`) and how many users were updated (element `1`), and `None` + to indicate that a user sync never deletes entries from the API. + """ + current_users = await bot.api_client.get('bot/users') + + # Pack API users and guild users into one common format, + # which is also hashable. We need hashability to be able + # to compare these easily later using sets. + api_users = { + user_dict['id']: User( + roles=tuple(sorted(user_dict.pop('roles'))), + **user_dict + ) + for user_dict in current_users + } + guild_users = { + member.id: User( + id=member.id, name=member.name, + discriminator=int(member.discriminator), avatar_hash=member.avatar, + roles=tuple(sorted(role.id for role in member.roles)), in_guild=True + ) + for member in guild.members + } + + users_to_create, users_to_update = get_users_for_sync(guild_users, api_users) + + for user in users_to_create: + await bot.api_client.post( + 'bot/users', + json={ + 'avatar_hash': user.avatar_hash, + 'discriminator': user.discriminator, + 'id': user.id, + 'in_guild': user.in_guild, + 'name': user.name, + 'roles': list(user.roles) + } + ) + + for user in users_to_update: + await bot.api_client.put( + f'bot/users/{user.id}', + json={ + 'avatar_hash': user.avatar_hash, + 'discriminator': user.discriminator, + 'id': user.id, + 'in_guild': user.in_guild, + 'name': user.name, + 'roles': list(user.roles) + } + ) + + return len(users_to_create), len(users_to_update), None diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index d6957e360..660620284 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -1,19 +1,11 @@ import logging -import random import time -from typing import Optional from discord import Colour, Embed -from discord.ext.commands import ( - BadArgument, Bot, - Context, group -) +from discord.ext.commands import Bot, Cog, Context, group -from bot.constants import ( - Channels, Cooldowns, ERROR_REPLIES, Keys, - MODERATION_ROLES, Roles, URLs -) -from bot.converters import TagContentConverter, TagNameConverter, ValidURL +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 @@ -27,108 +19,27 @@ TEST_CHANNELS = ( ) -class Tags: - """ - Save new tags and fetch existing tags. - """ +class Tags(Cog): + """Save new tags and fetch existing tags.""" def __init__(self, bot: Bot): self.bot = bot self.tag_cooldowns = {} - self.headers = {"X-API-KEY": Keys.site_api} - - async def get_tag_data(self, tag_name=None) -> dict: - """ - Retrieve the tag_data from our API - - :param tag_name: the tag to retrieve - :return: - if tag_name was provided, returns a dict with tag data. - if not, returns a list of dicts with all tag data. - - """ - params = {} - - if tag_name: - params["tag_name"] = tag_name - - response = await self.bot.http_session.get(URLs.site_tags_api, headers=self.headers, params=params) - tag_data = await response.json() - - return tag_data - - async def post_tag_data(self, tag_name: str, tag_content: str, image_url: Optional[str]) -> dict: - """ - Send some tag_data to our API to have it saved in the database. - - :param tag_name: The name of the tag to create or edit. - :param tag_content: The content of the tag. - :param image_url: The image URL of the tag, can be `None`. - :return: json response from the API in the following format: - { - 'success': bool - } - """ - - params = { - 'tag_name': tag_name, - 'tag_content': tag_content, - 'image_url': image_url - } - - response = await self.bot.http_session.post(URLs.site_tags_api, headers=self.headers, json=params) - tag_data = await response.json() - - return tag_data - - async def delete_tag_data(self, tag_name: str) -> dict: - """ - Delete a tag using our API. - - :param tag_name: The name of the tag to delete. - :return: json response from the API in the following format: - { - 'success': bool - } - """ - - params = {} - - if tag_name: - params['tag_name'] = tag_name - - response = await self.bot.http_session.delete(URLs.site_tags_api, headers=self.headers, json=params) - tag_data = await response.json() - - return tag_data @group(name='tags', aliases=('tag', 't'), hidden=True, invoke_without_command=True) - async def tags_group(self, ctx: Context, *, tag_name: TagNameConverter = None): + async def tags_group(self, ctx: Context, *, tag_name: TagNameConverter = None) -> None: """Show all known tags, a single tag, or run a subcommand.""" - await ctx.invoke(self.get_command, tag_name=tag_name) @tags_group.command(name='get', aliases=('show', 'g')) - async def get_command(self, ctx: Context, *, tag_name: TagNameConverter = None): - """ - Get a list of all tags or a specified tag. - - :param ctx: Discord message context - :param tag_name: - If provided, this function shows data for that specific tag. - If not provided, this function shows the caller a list of all tags. - """ - - def _command_on_cooldown(tag_name) -> bool: + async def get_command(self, ctx: Context, *, tag_name: TagNameConverter = None) -> None: + """Get a specified tag, or a list of all tags if no tag is specified.""" + def _command_on_cooldown(tag_name: str) -> bool: """ - Check if the command is currently on cooldown. - The cooldown duration is set in constants.py. + Check if the command is currently on cooldown, on a per-tag, per-channel basis. - This works on a per-tag, per-channel basis. - :param tag_name: The name of the command to check. - :return: True if the command is cooling down. Otherwise False. + The cooldown duration is set in constants.py. """ - now = time.time() cooldown_conditions = ( @@ -148,69 +59,32 @@ class Tags: f"Cooldown ends in {time_left:.1f} seconds.") return - tags = [] + if tag_name is not None: + tag = await self.bot.api_client.get(f'bot/tags/{tag_name}') + if ctx.channel.id not in TEST_CHANNELS: + self.tag_cooldowns[tag_name] = { + "time": time.time(), + "channel": ctx.channel.id + } + await ctx.send(embed=Embed.from_dict(tag['embed'])) - embed: Embed = Embed() - embed.colour = Colour.red() - tag_data = await self.get_tag_data(tag_name) - - # If we found something, prepare that data - if tag_data: - embed.colour = Colour.blurple() - - if tag_name: - log.debug(f"{ctx.author} requested the tag '{tag_name}'") - embed.title = tag_name - - if ctx.channel.id not in TEST_CHANNELS: - self.tag_cooldowns[tag_name] = { - "time": time.time(), - "channel": ctx.channel.id - } - - else: - embed.title = "**Current tags**" - - if isinstance(tag_data, list): - log.debug(f"{ctx.author} requested a list of all tags") - tags = [f"**»** {tag['tag_name']}" for tag in tag_data] - tags = sorted(tags) - - else: - embed.description = tag_data['tag_content'] - if tag_data['image_url'] is not None: - embed.set_image(url=tag_data['image_url']) - - # If its invoked from error handler just ignore it. - elif hasattr(ctx, "invoked_from_error_handler"): - return - # If not, prepare an error message. else: - embed.colour = Colour.red() - - if isinstance(tag_data, dict): - log.warning(f"{ctx.author} requested the tag '{tag_name}', but it could not be found.") - embed.description = f"**{tag_name}** is an unknown tag name. Please check the spelling and try again." + tags = await self.bot.api_client.get('bot/tags') + if not tags: + await ctx.send(embed=Embed( + description="**There are no tags in the database!**", + colour=Colour.red() + )) else: - log.warning(f"{ctx.author} requested a list of all tags, but the tags database was empty!") - embed.description = "**There are no tags in the database!**" - - if tag_name: - embed.set_footer(text="To show a list of all tags, use !tags.") - embed.title = "Tag not found." - - # Paginate if this is a list of all tags - if tags: - log.debug(f"Returning a paginated list of all tags.") - return await LinePaginator.paginate( - (lines for lines in tags), - ctx, embed, - footer_text="To show a tag, type !tags <tagname>.", - empty=False, - max_lines=15 - ) - - return await ctx.send(embed=embed) + embed: Embed = Embed(title="**Current tags**") + await LinePaginator.paginate( + sorted(f"**»** {tag['title']}" for tag in tags), + ctx, + embed, + footer_text="To show a tag, type !tags <tagname>.", + empty=False, + max_lines=15 + ) @tags_group.command(name='set', aliases=('add', 'edit', 's')) @with_role(*MODERATION_ROLES) @@ -218,96 +92,45 @@ class Tags: self, ctx: Context, tag_name: TagNameConverter, + *, tag_content: TagContentConverter, - image_url: ValidURL = None - ): - """ - Create a new tag or edit an existing one. - - :param ctx: discord message context - :param tag_name: The name of the tag to create or edit. - :param tag_content: The content of the tag. - :param image_url: An optional image for the tag. - """ + ) -> None: + """Create a new tag or update an existing one.""" + body = { + 'title': tag_name.lower().strip(), + 'embed': { + 'title': tag_name, + 'description': tag_content + } + } - tag_name = tag_name.lower().strip() - tag_content = tag_content.strip() + await self.bot.api_client.post('bot/tags', json=body) - embed = Embed() - embed.colour = Colour.red() - tag_data = await self.post_tag_data(tag_name, tag_content, image_url) + log.debug(f"{ctx.author} successfully added the following tag to our database: \n" + f"tag_name: {tag_name}\n" + f"tag_content: '{tag_content}'\n") - if tag_data.get("success"): - log.debug(f"{ctx.author} successfully added the following tag to our database: \n" - f"tag_name: {tag_name}\n" - f"tag_content: '{tag_content}'\n" - f"image_url: '{image_url}'") - embed.colour = Colour.blurple() - embed.title = "Tag successfully added" - embed.description = f"**{tag_name}** added to tag database." - else: - log.error("There was an unexpected database error when trying to add the following tag: \n" - f"tag_name: {tag_name}\n" - f"tag_content: '{tag_content}'\n" - f"image_url: '{image_url}'\n" - f"response: {tag_data}") - embed.title = "Database error" - embed.description = ("There was a problem adding the data to the tags database. " - "Please try again. If the problem persists, see the error logs.") - - return await ctx.send(embed=embed) + await ctx.send(embed=Embed( + title="Tag successfully added", + description=f"**{tag_name}** added to tag database.", + colour=Colour.blurple() + )) @tags_group.command(name='delete', aliases=('remove', 'rm', 'd')) @with_role(Roles.admin, Roles.owner) - async def delete_command(self, ctx: Context, *, tag_name: TagNameConverter): - """ - Remove a tag from the database. - - :param ctx: discord message context - :param tag_name: The name of the tag to delete. - """ - - tag_name = tag_name.lower().strip() - embed = Embed() - embed.colour = Colour.red() - tag_data = await self.delete_tag_data(tag_name) + async def delete_command(self, ctx: Context, *, tag_name: TagNameConverter) -> None: + """Remove a tag from the database.""" + await self.bot.api_client.delete(f'bot/tags/{tag_name}') - if tag_data.get("success") is True: - log.debug(f"{ctx.author} successfully deleted the tag called '{tag_name}'") - embed.colour = Colour.blurple() - embed.title = tag_name - embed.description = f"Tag successfully removed: {tag_name}." - - elif tag_data.get("success") is False: - log.debug(f"{ctx.author} tried to delete a tag called '{tag_name}', but the tag does not exist.") - embed.colour = Colour.red() - embed.title = tag_name - embed.description = "Tag doesn't appear to exist." - - else: - log.error("There was an unexpected database error when trying to delete the following tag: \n" - f"tag_name: {tag_name}\n" - f"response: {tag_data}") - embed.title = "Database error" - embed.description = ("There was an unexpected error with deleting the data from the tags database. " - "Please try again. If the problem persists, see the error logs.") - - return await ctx.send(embed=embed) - - @get_command.error - @set_command.error - @delete_command.error - async def command_error(self, ctx, error): - if isinstance(error, BadArgument): - embed = Embed() - embed.colour = Colour.red() - embed.description = str(error) - embed.title = random.choice(ERROR_REPLIES) - await ctx.send(embed=embed) - else: - log.error(f"Unhandled tag command error: {error} ({error.original})") + log.debug(f"{ctx.author} successfully deleted the tag called '{tag_name}'") + await ctx.send(embed=Embed( + title=tag_name, + description=f"Tag successfully removed: {tag_name}.", + colour=Colour.blurple() + )) -def setup(bot): +def setup(bot: Bot) -> None: + """Tags cog load.""" bot.add_cog(Tags(bot)) log.info("Cog loaded: Tags") diff --git a/bot/cogs/token_remover.py b/bot/cogs/token_remover.py index 05298a2ff..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): @@ -44,9 +42,16 @@ class TokenRemover: @property def mod_log(self) -> ModLog: + """Get currently loaded ModLog cog instance.""" return self.bot.get_cog("ModLog") - async def on_message(self, msg: Message): + @Cog.listener() + async def on_message(self, msg: Message) -> None: + """ + Check each message for a string that matches Discord's token pattern. + + See: https://discordapp.com/developers/docs/reference#snowflakes + """ if msg.author.bot: return @@ -83,6 +88,11 @@ class TokenRemover: @staticmethod def is_valid_user_id(b64_content: str) -> bool: + """ + Check potential token to see if it contains a valid Discord user ID. + + See: https://discordapp.com/developers/docs/reference#snowflakes + """ b64_content += '=' * (-len(b64_content) % 4) try: @@ -93,6 +103,11 @@ class TokenRemover: @staticmethod def is_valid_timestamp(b64_content: str) -> bool: + """ + Check potential token to see if it contains a valid timestamp. + + See: https://discordapp.com/developers/docs/reference#snowflakes + """ b64_content += '=' * (-len(b64_content) % 4) try: @@ -103,6 +118,7 @@ class TokenRemover: return snowflake_time(snowflake + TOKEN_EPOCH) < DISCORD_EPOCH_TIMESTAMP -def setup(bot: Bot): +def setup(bot: Bot) -> None: + """Token Remover cog load.""" bot.add_cog(TokenRemover(bot)) log.info("Cog loaded: TokenRemover") diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index 0c6d9d2ba..62e2fb03f 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -1,40 +1,35 @@ 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 AutoShardedBot, 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: - """ - A selection of utilities which don't have a clear category. - """ +class Utils(Cog): + """A selection of utilities which don't have a clear category.""" - def __init__(self, bot: AutoShardedBot): + def __init__(self, bot: Bot): self.bot = bot self.base_pep_url = "http://www.python.org/dev/peps/pep-" self.base_github_pep_url = "https://raw.githubusercontent.com/python/peps/master/pep-" @command(name='pep', aliases=('get_pep', 'p')) - async def pep_command(self, ctx: Context, pep_number: str): - """ - Fetches information about a PEP and sends it to the channel. - """ - + async def pep_command(self, ctx: Context, pep_number: str) -> None: + """Fetches information about a PEP and sends it to the channel.""" if pep_number.isdigit(): pep_number = int(pep_number) else: - return await ctx.invoke(self.bot.get_command("help"), "pep") + await ctx.invoke(self.bot.get_command("help"), "pep") + return # Newer PEPs are written in RST instead of txt if pep_number > 542: @@ -90,11 +85,8 @@ class Utils: @command() @in_channel(Channels.bot, bypass_roles=STAFF_ROLES) - async def charinfo(self, ctx, *, characters: str): - """ - Shows you information on up to 25 unicode characters. - """ - + async def charinfo(self, ctx: Context, *, characters: str) -> None: + """Shows you information on up to 25 unicode characters.""" match = re.match(r"<(a?):(\w+):(\d+)>", characters) if match: embed = Embed( @@ -105,12 +97,14 @@ class Utils: ) ) embed.colour = Colour.red() - return await ctx.send(embed=embed) + await ctx.send(embed=embed) + return if len(characters) > 25: embed = Embed(title=f"Too many characters ({len(characters)}/25)") embed.colour = Colour.red() - return await ctx.send(embed=embed) + await ctx.send(embed=embed) + return def get_info(char): digit = f"{ord(char):x}" @@ -133,14 +127,8 @@ class Utils: await ctx.send(embed=embed) - async def __error(self, ctx, error): - 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): +def setup(bot: Bot) -> None: + """Utils cog load.""" bot.add_cog(Utils(bot)) log.info("Cog loaded: Utils") diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 56fcd63eb..b0c250603 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,19 +28,20 @@ If you'd like to unsubscribe from the announcement notifications, simply send `! """ -class Verification: - """ - User verification and role self-management - """ +class Verification(Cog): + """User verification and role self-management.""" def __init__(self, bot: Bot): self.bot = bot @property def mod_log(self) -> ModLog: + """Get currently loaded ModLog cog instance.""" return self.bot.get_cog("ModLog") - async def on_message(self, message: Message): + @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: return # They're a bot, ignore @@ -74,11 +75,8 @@ class Verification: @command(name='accept', aliases=('verify', 'verified', 'accepted'), hidden=True) @without_role(Roles.verified) @in_channel(Channels.verification) - async def accept_command(self, ctx: Context, *_): # We don't actually care about the args - """ - Accept our rules and gain access to the rest of the server - """ - + async def accept_command(self, ctx: Context, *_) -> None: # We don't actually care about the args + """Accept our rules and gain access to the rest of the server.""" log.debug(f"{ctx.author} called !accept. Assigning the 'Developer' role.") await ctx.author.add_roles(Object(Roles.verified), reason="Accepted the rules") try: @@ -97,11 +95,8 @@ class Verification: @command(name='subscribe') @in_channel(Channels.bot) - async def subscribe_command(self, ctx: Context, *_): # We don't actually care about the args - """ - Subscribe to announcement notifications by assigning yourself the role - """ - + async def subscribe_command(self, ctx: Context, *_) -> None: # We don't actually care about the args + """Subscribe to announcement notifications by assigning yourself the role.""" has_role = False for role in ctx.author.roles: @@ -110,9 +105,8 @@ class Verification: break if has_role: - return await ctx.send( - f"{ctx.author.mention} You're already subscribed!", - ) + await ctx.send(f"{ctx.author.mention} You're already subscribed!") + return log.debug(f"{ctx.author} called !subscribe. Assigning the 'Announcements' role.") await ctx.author.add_roles(Object(Roles.announcements), reason="Subscribed to announcements") @@ -125,11 +119,8 @@ class Verification: @command(name='unsubscribe') @in_channel(Channels.bot) - async def unsubscribe_command(self, ctx: Context, *_): # We don't actually care about the args - """ - Unsubscribe from announcement notifications by removing the role from yourself - """ - + async def unsubscribe_command(self, ctx: Context, *_) -> None: # We don't actually care about the args + """Unsubscribe from announcement notifications by removing the role from yourself.""" has_role = False for role in ctx.author.roles: @@ -138,9 +129,8 @@ class Verification: break if not has_role: - return await ctx.send( - f"{ctx.author.mention} You're already unsubscribed!" - ) + await ctx.send(f"{ctx.author.mention} You're already unsubscribed!") + return log.debug(f"{ctx.author} called !unsubscribe. Removing the 'Announcements' role.") await ctx.author.remove_roles(Object(Roles.announcements), reason="Unsubscribed from announcements") @@ -152,17 +142,21 @@ class Verification: ) @staticmethod - def __global_check(ctx: Context): - """ - Block any command within the verification channel that is not !accept. - """ + async def cog_command_error(ctx: Context, error: Exception) -> None: + """Check for & ignore any InChannelCheckFailure.""" + if isinstance(error, InChannelCheckFailure): + 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" else: return True -def setup(bot): +def setup(bot: Bot) -> None: + """Verification cog load.""" bot.add_cog(Verification(bot)) log.info("Cog loaded: Verification") diff --git a/bot/cogs/watchchannels/__init__.py b/bot/cogs/watchchannels/__init__.py new file mode 100644 index 000000000..86e1050fa --- /dev/null +++ b/bot/cogs/watchchannels/__init__.py @@ -0,0 +1,18 @@ +import logging + +from discord.ext.commands import Bot + +from .bigbrother import BigBrother +from .talentpool import TalentPool + + +log = logging.getLogger(__name__) + + +def setup(bot: Bot) -> None: + """Monitoring cogs load.""" + bot.add_cog(BigBrother(bot)) + log.info("Cog loaded: BigBrother") + + bot.add_cog(TalentPool(bot)) + log.info("Cog loaded: TalentPool") diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/cogs/watchchannels/bigbrother.py new file mode 100644 index 000000000..e191c2dbc --- /dev/null +++ b/bot/cogs/watchchannels/bigbrother.py @@ -0,0 +1,100 @@ +import logging +from collections import ChainMap +from typing import Union + +from discord import User +from discord.ext.commands import Bot, Cog, Context, group + +from bot.constants import Channels, Roles, Webhooks +from bot.decorators import with_role +from bot.utils.moderation import post_infraction +from .watchchannel import WatchChannel, proxy_user + +log = logging.getLogger(__name__) + + +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: + super().__init__( + bot, + destination=Channels.big_brother_logs, + webhook_id=Webhooks.big_brother, + api_endpoint='bot/infractions', + api_default_params={'active': 'true', 'type': 'watch', 'ordering': '-inserted_at'}, + logger=log + ) + + @group(name='bigbrother', aliases=('bb',), invoke_without_command=True) + @with_role(Roles.owner, Roles.admin, Roles.moderator) + async def bigbrother_group(self, ctx: Context) -> None: + """Monitors users by relaying their messages to the Big Brother watch channel.""" + await ctx.invoke(self.bot.get_command("help"), "bigbrother") + + @bigbrother_group.command(name='watched', aliases=('all', 'list')) + @with_role(Roles.owner, Roles.admin, Roles.moderator) + async def watched_command(self, ctx: Context, update_cache: bool = True) -> None: + """ + Shows the users that are currently being monitored by Big Brother. + + The optional kwarg `update_cache` can be used to update the user + cache using the API before listing the users. + """ + await self.list_watched_users(ctx, update_cache) + + @bigbrother_group.command(name='watch', aliases=('w',)) + @with_role(Roles.owner, Roles.admin, Roles.moderator) + async def watch_command(self, ctx: Context, user: Union[User, proxy_user], *, reason: str) -> None: + """ + Relay messages sent by the given `user` to the `#big-brother` channel. + + A `reason` for adding the user to Big Brother is required and will be displayed + in the header when relaying messages of this user to the watchchannel. + """ + if user.bot: + await ctx.send(f":x: I'm sorry {ctx.author}, I'm afraid I can't do that. I only watch humans.") + return + + if not await self.fetch_user_cache(): + await ctx.send(f":x: Updating the user cache failed, can't watch user {user}") + return + + if user.id in self.watched_users: + await ctx.send(":x: The specified user is already being watched.") + return + + response = await post_infraction( + ctx, user, type='watch', reason=reason, hidden=True + ) + + if response is not None: + self.watched_users[user.id] = response + await ctx.send(f":white_check_mark: Messages sent by {user} will now be relayed to Big Brother.") + + @bigbrother_group.command(name='unwatch', aliases=('uw',)) + @with_role(Roles.owner, Roles.admin, Roles.moderator) + async def unwatch_command(self, ctx: Context, user: Union[User, proxy_user], *, reason: str) -> None: + """Stop relaying messages by the given `user`.""" + active_watches = await self.bot.api_client.get( + self.api_endpoint, + params=ChainMap( + self.api_default_params, + {"user__id": str(user.id)} + ) + ) + if active_watches: + [infraction] = active_watches + + await self.bot.api_client.patch( + f"{self.api_endpoint}/{infraction['id']}", + json={'active': False} + ) + + await post_infraction(ctx, user, type='watch', reason=f"Unwatched: {reason}", hidden=True, active=False) + + await ctx.send(f":white_check_mark: Messages sent by {user} will no longer be relayed.") + + self._remove_user(user.id) + else: + await ctx.send(":x: The specified user is currently not being watched.") diff --git a/bot/cogs/watchchannels/talentpool.py b/bot/cogs/watchchannels/talentpool.py new file mode 100644 index 000000000..ffe7693a9 --- /dev/null +++ b/bot/cogs/watchchannels/talentpool.py @@ -0,0 +1,231 @@ +import logging +import textwrap +from collections import ChainMap +from typing import Union + +from discord import Color, Embed, Member, User +from discord.ext.commands import Bot, Cog, Context, group + +from bot.api import ResponseCodeError +from bot.constants import Channels, Guild, Roles, Webhooks +from bot.decorators import with_role +from bot.pagination import LinePaginator +from .watchchannel import WatchChannel, proxy_user + +log = logging.getLogger(__name__) +STAFF_ROLES = Roles.owner, Roles.admin, Roles.moderator, Roles.helpers # <- In constants after the merge? + + +class TalentPool(WatchChannel, Cog, name="Talentpool"): + """Relays messages of helper candidates to a watch channel to observe them.""" + + def __init__(self, bot: Bot) -> None: + super().__init__( + bot, + destination=Channels.talent_pool, + webhook_id=Webhooks.talent_pool, + api_endpoint='bot/nominations', + api_default_params={'active': 'true', 'ordering': '-inserted_at'}, + logger=log, + ) + + @group(name='talentpool', aliases=('tp', 'talent', 'nomination', 'n'), invoke_without_command=True) + @with_role(Roles.owner, Roles.admin, Roles.moderator) + async def nomination_group(self, ctx: Context) -> None: + """Highlights the activity of helper nominees by relaying their messages to the talent pool channel.""" + await ctx.invoke(self.bot.get_command("help"), "talentpool") + + @nomination_group.command(name='watched', aliases=('all', 'list')) + @with_role(Roles.owner, Roles.admin, Roles.moderator) + async def watched_command(self, ctx: Context, update_cache: bool = True) -> None: + """ + Shows the users that are currently being monitored in the talent pool. + + The optional kwarg `update_cache` can be used to update the user + cache using the API before listing the users. + """ + await self.list_watched_users(ctx, update_cache) + + @nomination_group.command(name='watch', aliases=('w', 'add', 'a')) + @with_role(Roles.owner, Roles.admin, Roles.moderator) + async def watch_command(self, ctx: Context, user: Union[Member, User, proxy_user], *, reason: str) -> None: + """ + Relay messages sent by the given `user` to the `#talent-pool` channel. + + A `reason` for adding the user to the talent pool is required and will be displayed + in the header when relaying messages of this user to the channel. + """ + if user.bot: + await ctx.send(f":x: I'm sorry {ctx.author}, I'm afraid I can't do that. I only watch humans.") + return + + if isinstance(user, Member) and any(role.id in STAFF_ROLES for role in user.roles): + await ctx.send(f":x: Nominating staff members, eh? Here's a cookie :cookie:") + return + + if not await self.fetch_user_cache(): + await ctx.send(f":x: Failed to update the user cache; can't add {user}") + return + + if user.id in self.watched_users: + await ctx.send(":x: The specified user is already being watched in the talent pool") + return + + # Manual request with `raise_for_status` as False because we want the actual response + session = self.bot.api_client.session + url = self.bot.api_client._url_for(self.api_endpoint) + kwargs = { + 'json': { + 'actor': ctx.author.id, + 'reason': reason, + 'user': user.id + }, + 'raise_for_status': False, + } + async with session.post(url, **kwargs) as resp: + response_data = await resp.json() + + if resp.status == 400 and response_data.get('user', False): + await ctx.send(":x: The specified user can't be found in the database tables") + return + else: + resp.raise_for_status() + + self.watched_users[user.id] = response_data + await ctx.send(f":white_check_mark: Messages sent by {user} will now be relayed to the talent pool channel") + + @nomination_group.command(name='history', aliases=('info', 'search')) + @with_role(Roles.owner, Roles.admin, Roles.moderator) + async def history_command(self, ctx: Context, user: Union[User, proxy_user]) -> None: + """Shows the specified user's nomination history.""" + result = await self.bot.api_client.get( + self.api_endpoint, + params={ + 'user__id': str(user.id), + 'ordering': "-active,-inserted_at" + } + ) + if not result: + await ctx.send(":warning: This user has never been nominated") + return + + embed = Embed( + title=f"Nominations for {user.display_name} `({user.id})`", + color=Color.blue() + ) + lines = [self._nomination_to_string(nomination) for nomination in result] + await LinePaginator.paginate( + lines, + ctx=ctx, + embed=embed, + empty=True, + max_lines=3, + max_size=1000 + ) + + @nomination_group.command(name='unwatch', aliases=('end', )) + @with_role(Roles.owner, Roles.admin, Roles.moderator) + async def unwatch_command(self, ctx: Context, user: Union[User, proxy_user], *, reason: str) -> None: + """ + Ends the active nomination of the specified user with the given reason. + + Providing a `reason` is required. + """ + active_nomination = await self.bot.api_client.get( + self.api_endpoint, + params=ChainMap( + self.api_default_params, + {"user__id": str(user.id)} + ) + ) + + if not active_nomination: + await ctx.send(":x: The specified user does not have an active nomination") + return + + [nomination] = active_nomination + await self.bot.api_client.patch( + f"{self.api_endpoint}/{nomination['id']}", + json={'end_reason': reason, 'active': False} + ) + await ctx.send(f":white_check_mark: Messages sent by {user} will no longer be relayed") + self._remove_user(user.id) + + @nomination_group.group(name='edit', aliases=('e',), invoke_without_command=True) + @with_role(Roles.owner, Roles.admin, Roles.moderator) + async def nomination_edit_group(self, ctx: Context) -> None: + """Commands to edit nominations.""" + await ctx.invoke(self.bot.get_command("help"), "talentpool", "edit") + + @nomination_edit_group.command(name='reason') + @with_role(Roles.owner, Roles.admin, Roles.moderator) + async def edit_reason_command(self, ctx: Context, nomination_id: int, *, reason: str) -> None: + """ + Edits the reason/unnominate reason for the nomination with the given `id` depending on the status. + + If the nomination is active, the reason for nominating the user will be edited; + If the nomination is no longer active, the reason for ending the nomination will be edited instead. + """ + try: + nomination = await self.bot.api_client.get(f"{self.api_endpoint}/{nomination_id}") + except ResponseCodeError as e: + if e.response.status == 404: + self.log.trace(f"Nomination API 404: Can't nomination with id {nomination_id}") + await ctx.send(f":x: Can't find a nomination with id `{nomination_id}`") + return + else: + raise + + field = "reason" if nomination["active"] else "end_reason" + + self.log.trace(f"Changing {field} for nomination with id {nomination_id} to {reason}") + + await self.bot.api_client.patch( + f"{self.api_endpoint}/{nomination_id}", + json={field: reason} + ) + + await ctx.send(f":white_check_mark: Updated the {field} of the nomination!") + + def _nomination_to_string(self, nomination_object: dict) -> str: + """Creates a string representation of a nomination.""" + guild = self.bot.get_guild(Guild.id) + + actor_id = nomination_object["actor"] + actor = guild.get_member(actor_id) + + active = nomination_object["active"] + log.debug(active) + log.debug(type(nomination_object["inserted_at"])) + + start_date = self._get_human_readable(nomination_object["inserted_at"]) + if active: + lines = textwrap.dedent( + f""" + =============== + Status: **Active** + Date: {start_date} + Actor: {actor.mention if actor else actor_id} + Reason: {nomination_object["reason"]} + Nomination ID: `{nomination_object["id"]}` + =============== + """ + ) + else: + end_date = self._get_human_readable(nomination_object["ended_at"]) + lines = textwrap.dedent( + f""" + =============== + Status: Inactive + Date: {start_date} + Actor: {actor.mention if actor else actor_id} + Reason: {nomination_object["reason"]} + + End date: {end_date} + Unwatch reason: {nomination_object["end_reason"]} + Nomination ID: `{nomination_object["id"]}` + =============== + """ + ) + + return lines.strip() diff --git a/bot/cogs/watchchannels/watchchannel.py b/bot/cogs/watchchannels/watchchannel.py new file mode 100644 index 000000000..e78282900 --- /dev/null +++ b/bot/cogs/watchchannels/watchchannel.py @@ -0,0 +1,357 @@ +import asyncio +import datetime +import logging +import re +import textwrap +from abc import abstractmethod +from collections import defaultdict, deque +from dataclasses import dataclass +from typing import Optional + +import discord +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 CogABCMeta, messages +from bot.utils.time import time_since + +log = logging.getLogger(__name__) + +URL_RE = re.compile(r"(https?://[^\s]+)") + + +def proxy_user(user_id: str) -> Object: + """A proxy user object that mocks a real User instance for when the later is not available.""" + try: + user_id = int(user_id) + except ValueError: + raise BadArgument + + user = Object(user_id) + user.mention = user.id + user.display_name = f"<@{user.id}>" + user.avatar_url_as = lambda static_format: None + user.bot = False + + return user + + +@dataclass +class MessageHistory: + """Represents a watch channel's message history.""" + + last_author: Optional[int] = None + last_channel: Optional[int] = None + message_count: int = 0 + + +class WatchChannel(metaclass=CogABCMeta): + """ABC with functionality for relaying users' messages to a certain channel.""" + + @abstractmethod + def __init__( + self, + bot: Bot, + destination: int, + webhook_id: int, + api_endpoint: str, + api_default_params: dict, + logger: logging.Logger + ) -> None: + self.bot = bot + + self.destination = destination # E.g., Channels.big_brother_logs + self.webhook_id = webhook_id # E.g., Webhooks.big_brother + self.api_endpoint = api_endpoint # E.g., 'bot/infractions' + self.api_default_params = api_default_params # E.g., {'active': 'true', 'type': 'watch'} + self.log = logger # Logger of the child cog for a correct name in the logs + + self._consume_task = None + self.watched_users = defaultdict(dict) + self.message_queue = defaultdict(lambda: defaultdict(deque)) + self.consumption_queue = {} + self.retries = 5 + self.retry_delay = 10 + self.channel = None + self.webhook = None + self.message_history = MessageHistory() + + self._start = self.bot.loop.create_task(self.start_watchchannel()) + + @property + def modlog(self) -> ModLog: + """Provides access to the ModLog cog for alert purposes.""" + return self.bot.get_cog("ModLog") + + @property + def consuming_messages(self) -> bool: + """Checks if a consumption task is currently running.""" + if self._consume_task is None: + return False + + if self._consume_task.done(): + exc = self._consume_task.exception() + if exc: + self.log.exception( + f"The message queue consume task has failed with:", + exc_info=exc + ) + return False + + return True + + async def start_watchchannel(self) -> None: + """Starts the watch channel by getting the channel, webhook, and user cache ready.""" + await self.bot.wait_until_ready() + + 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}`") + + try: + 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: + self.log.error("Failed to start the watch channel; unloading the cog.") + + message = textwrap.dedent( + f""" + An error occurred while loading the text channel or webhook. + + TextChannel: {"**Failed to load**" if self.channel is None else "Loaded successfully"} + Webhook: {"**Failed to load**" if self.webhook is None else "Loaded successfully"} + + The Cog has been unloaded. + """ + ) + + await self.modlog.send_log_message( + title=f"Error: Failed to initialize the {self.__class__.__name__} watch channel", + text=message, + ping_everyone=True, + icon_url=Icons.token_removed, + colour=Color.red() + ) + + self.bot.remove_cog(self.__class__.__name__) + return + + if not await self.fetch_user_cache(): + await self.modlog.send_log_message( + title=f"Warning: Failed to retrieve user cache for the {self.__class__.__name__} watch channel", + text="Could not retrieve the list of watched users from the API and messages will not be relayed.", + ping_everyone=True, + icon=Icons.token_removed, + color=Color.red() + ) + + async def fetch_user_cache(self) -> bool: + """ + Fetches watched users from the API and updates the watched user cache accordingly. + + This function returns `True` if the update succeeded. + """ + try: + data = await self.bot.api_client.get(self.api_endpoint, params=self.api_default_params) + except ResponseCodeError as err: + self.log.exception(f"Failed to fetch the watched users from the API", exc_info=err) + return False + + self.watched_users = defaultdict(dict) + + for entry in data: + user_id = entry.pop('user') + self.watched_users[user_id] = entry + + 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: + if not self.consuming_messages: + self._consume_task = self.bot.loop.create_task(self.consume_messages()) + + self.log.trace(f"Received message: {msg.content} ({len(msg.attachments)} attachments)") + self.message_queue[msg.author.id][msg.channel.id].append(msg) + + async def consume_messages(self, delay_consumption: bool = True) -> None: + """Consumes the message queues to log watched users' messages.""" + if delay_consumption: + self.log.trace(f"Sleeping {BigBrotherConfig.log_delay} seconds before consuming message queue") + await asyncio.sleep(BigBrotherConfig.log_delay) + + self.log.trace(f"Started consuming the message queue") + + # If the previous consumption Task failed, first consume the existing comsumption_queue + if not self.consumption_queue: + self.consumption_queue = self.message_queue.copy() + self.message_queue.clear() + + for user_channel_queues in self.consumption_queue.values(): + for channel_queue in user_channel_queues.values(): + while channel_queue: + msg = channel_queue.popleft() + + self.log.trace(f"Consuming message {msg.id} ({len(msg.attachments)} attachments)") + await self.relay_message(msg) + + self.consumption_queue.clear() + + if self.message_queue: + self.log.trace("Channel queue not empty: Continuing consuming queues") + self._consume_task = self.bot.loop.create_task(self.consume_messages(delay_consumption=False)) + else: + self.log.trace("Done consuming messages.") + + async def webhook_send( + self, + content: Optional[str] = None, + username: Optional[str] = None, + avatar_url: Optional[str] = None, + embed: Optional[Embed] = None, + ) -> None: + """Sends a message to the webhook with the specified kwargs.""" + try: + await self.webhook.send(content=content, username=username, avatar_url=avatar_url, embed=embed) + except discord.HTTPException as exc: + self.log.exception( + f"Failed to send a message to the webhook", + exc_info=exc + ) + + async def relay_message(self, msg: Message) -> None: + """Relays the message to the relevant watch channel.""" + limit = BigBrotherConfig.header_message_limit + + if ( + msg.author.id != self.message_history.last_author + or msg.channel.id != self.message_history.last_channel + or self.message_history.message_count >= limit + ): + self.message_history = MessageHistory(last_author=msg.author.id, last_channel=msg.channel.id) + + await self.send_header(msg) + + cleaned_content = msg.clean_content + + if cleaned_content: + # Put all non-media URLs in a code block to prevent embeds + media_urls = {embed.url for embed in msg.embeds if embed.type in ("image", "video")} + for url in URL_RE.findall(cleaned_content): + if url not in media_urls: + cleaned_content = cleaned_content.replace(url, f"`{url}`") + await self.webhook_send( + cleaned_content, + username=msg.author.display_name, + avatar_url=msg.author.avatar_url + ) + + if msg.attachments: + try: + await messages.send_attachments(msg, self.webhook) + except (errors.Forbidden, errors.NotFound): + e = Embed( + description=":x: **This message contained an attachment, but it could not be retrieved**", + color=Color.red() + ) + await self.webhook_send( + embed=e, + username=msg.author.display_name, + avatar_url=msg.author.avatar_url + ) + except discord.HTTPException as exc: + self.log.exception( + f"Failed to send an attachment to the webhook", + exc_info=exc + ) + + self.message_history.message_count += 1 + + async def send_header(self, msg: Message) -> None: + """Sends a header embed with information about the relayed messages to the watch channel.""" + user_id = msg.author.id + + guild = self.bot.get_guild(GuildConfig.id) + actor = guild.get_member(self.watched_users[user_id]['actor']) + actor = actor.display_name if actor else self.watched_users[user_id]['actor'] + + inserted_at = self.watched_users[user_id]['inserted_at'] + time_delta = self._get_time_delta(inserted_at) + + reason = self.watched_users[user_id]['reason'] + + embed = Embed(description=f"{msg.author.mention} in [#{msg.channel.name}]({msg.jump_url})") + embed.set_footer(text=f"Added {time_delta} by {actor} | Reason: {reason}") + + await self.webhook_send(embed=embed, username=msg.author.display_name, avatar_url=msg.author.avatar_url) + + async def list_watched_users(self, ctx: Context, update_cache: bool = True) -> None: + """ + Gives an overview of the watched user list for this channel. + + The optional kwarg `update_cache` specifies whether the cache should + be refreshed by polling the API. + """ + if update_cache: + if not await self.fetch_user_cache(): + await ctx.send(f":x: Failed to update {self.__class__.__name__} user cache, serving from cache") + update_cache = False + + lines = [] + for user_id, user_data in self.watched_users.items(): + inserted_at = user_data['inserted_at'] + time_delta = self._get_time_delta(inserted_at) + lines.append(f"• <@{user_id}> (added {time_delta})") + + lines = lines or ("There's nothing here yet.",) + embed = Embed( + title=f"{self.__class__.__name__} watched users ({'updated' if update_cache else 'cached'})", + color=Color.blue() + ) + await LinePaginator.paginate(lines, ctx, embed, empty=False) + + @staticmethod + def _get_time_delta(time_string: str) -> str: + """Returns the time in human-readable time delta format.""" + date_time = datetime.datetime.strptime( + time_string, + "%Y-%m-%dT%H:%M:%S.%fZ" + ).replace(tzinfo=None) + time_delta = time_since(date_time, precision="minutes", max_units=1) + + return time_delta + + @staticmethod + def _get_human_readable(time_string: str, output_format: str = "%Y-%m-%d %H:%M:%S") -> str: + date_time = datetime.datetime.strptime( + time_string, + "%Y-%m-%dT%H:%M:%S.%fZ" + ).replace(tzinfo=None) + return date_time.strftime(output_format) + + def _remove_user(self, user_id: int) -> None: + """Removes a user from a watch channel.""" + self.watched_users.pop(user_id, None) + self.message_queue.pop(user_id, None) + self.consumption_queue.pop(user_id, None) + + def cog_unload(self) -> None: + """Takes care of unloading the cog and canceling the consumption task.""" + self.log.trace(f"Unloading the cog") + if not self._consume_task.done(): + self._consume_task.cancel() + try: + self._consume_task.result() + except asyncio.CancelledError as e: + self.log.exception( + f"The consume task was canceled. Messages may be lost.", + exc_info=e + ) diff --git a/bot/cogs/wolfram.py b/bot/cogs/wolfram.py index e8b16b243..ab0ed2472 100644 --- a/bot/cogs/wolfram.py +++ b/bot/cogs/wolfram.py @@ -1,15 +1,17 @@ import logging from io import BytesIO -from typing import List, Optional, Tuple +from typing import Callable, 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 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__) @@ -35,18 +37,7 @@ async def send_embed( img_url: str = None, f: discord.File = None ) -> None: - """ - Generates an embed with wolfram as the author, with message_txt as description, - adds custom colour if specified, a footer and image (could be a file with f param) and sends - the embed through ctx - :param ctx: Context - :param message_txt: str - Message to be sent - :param colour: int - Default: Colours.soft_red - Colour of embed - :param footer: str - Default: None - Adds a footer to the embed - :param img_url:str - Default: None - Adds an image to the embed - :param f: discord.File - Default: None - Add a file to the msg, often attached as image to embed - """ - + """Generate & send a response embed with Wolfram as the author.""" embed = Embed(colour=colour) embed.description = message_txt embed.set_author(name="Wolfram Alpha", @@ -61,16 +52,12 @@ async def send_embed( await ctx.send(embed=embed, file=f) -def custom_cooldown(*ignore: List[int]) -> check: +def custom_cooldown(*ignore: List[int]) -> Callable: """ - Custom cooldown mapping that applies a specific requests per day to users. - Staff is ignored by the user cooldown, however the cooldown implements a - total amount of uses per day for the entire guild. (Configurable in configs) + Implement per-user and per-guild cooldowns for requests to the Wolfram API. - :param ignore: List[int] -- list of ids of roles to be ignored by user cooldown - :return: check + A list of roles may be provided to ignore the per-user cooldown """ - async def predicate(ctx: Context) -> bool: user_bucket = usercd.get_bucket(ctx.message) @@ -79,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 @@ -105,8 +94,8 @@ def custom_cooldown(*ignore: List[int]) -> check: return check(predicate) -async def get_pod_pages(ctx, bot, query: str) -> Optional[List[Tuple]]: - # Give feedback that the bot is working. +async def get_pod_pages(ctx: Context, bot: Bot, query: str) -> Optional[List[Tuple]]: + """Get the Wolfram API pod pages for the provided query.""" async with ctx.channel.typing(): url_str = parse.urlencode({ "input": query, @@ -121,17 +110,27 @@ async def get_pod_pages(ctx, bot, query: str) -> Optional[List[Tuple]]: 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) @@ -149,10 +148,8 @@ async def get_pod_pages(ctx, bot, query: str) -> Optional[List[Tuple]]: return pages -class Wolfram: - """ - Commands for interacting with the Wolfram|Alpha API. - """ +class Wolfram(Cog): + """Commands for interacting with the Wolfram|Alpha API.""" def __init__(self, bot: commands.Bot): self.bot = bot @@ -160,14 +157,7 @@ class Wolfram: @group(name="wolfram", aliases=("wolf", "wa"), invoke_without_command=True) @custom_cooldown(*STAFF_ROLES) async def wolfram_command(self, ctx: Context, *, query: str) -> None: - """ - Requests all answers on a single image, - sends an image of all related pods - - :param ctx: Context - :param query: str - string request to api - """ - + """Requests all answers on a single image, sends an image of all related pods.""" url_str = parse.urlencode({ "i": query, "appid": APPID, @@ -191,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." @@ -203,13 +197,10 @@ class Wolfram: @custom_cooldown(*STAFF_ROLES) async def wolfram_page_command(self, ctx: Context, *, query: str) -> None: """ - Requests a drawn image of given query - Keywords worth noting are, "like curve", "curve", "graph", "pokemon", etc + Requests a drawn image of given query. - :param ctx: Context - :param query: str - string request to api + Keywords worth noting are, "like curve", "curve", "graph", "pokemon", etc. """ - pages = await get_pod_pages(ctx, self.bot, query) if not pages: @@ -225,15 +216,12 @@ class Wolfram: @wolfram_command.command(name="cut", aliases=("c",)) @custom_cooldown(*STAFF_ROLES) - async def wolfram_cut_command(self, ctx, *, query: str) -> None: + async def wolfram_cut_command(self, ctx: Context, *, query: str) -> None: """ - Requests a drawn image of given query - Keywords worth noting are, "like curve", "curve", "graph", "pokemon", etc + Requests a drawn image of given query. - :param ctx: Context - :param query: str - string request to api + Keywords worth noting are, "like curve", "curve", "graph", "pokemon", etc. """ - pages = await get_pod_pages(ctx, self.bot, query) if not pages: @@ -249,14 +237,7 @@ class Wolfram: @wolfram_command.command(name="short", aliases=("sh", "s")) @custom_cooldown(*STAFF_ROLES) async def wolfram_short_command(self, ctx: Context, *, query: str) -> None: - """ - Requests an answer to a simple question - Responds in plaintext - - :param ctx: Context - :param query: str - string request to api - """ - + """Requests an answer to a simple question.""" url_str = parse.urlencode({ "i": query, "appid": APPID, @@ -272,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 @@ -284,5 +267,6 @@ class Wolfram: def setup(bot: commands.Bot) -> None: + """Wolfram cog load.""" bot.add_cog(Wolfram(bot)) log.info("Cog loaded: Wolfram") diff --git a/bot/constants.py b/bot/constants.py index 17e60a418..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 @@ -356,6 +358,14 @@ class Channels(metaclass=YAMLGetter): verification: int +class Webhooks(metaclass=YAMLGetter): + section = "guild" + subsection = "webhooks" + + talent_pool: int + big_brother: int + + class Roles(metaclass=YAMLGetter): section = "guild" subsection = "roles" @@ -364,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 @@ -385,37 +394,26 @@ class Guild(metaclass=YAMLGetter): class Keys(metaclass=YAMLGetter): section = "keys" - deploy_bot: str - deploy_site: str site_api: str -class RabbitMQ(metaclass=YAMLGetter): - section = "rabbitmq" - - host: str - password: str - port: int - username: str - - class URLs(metaclass=YAMLGetter): section = "urls" + # Snekbox endpoints + snekbox_eval_api: str + # Discord API endpoints discord_api: str discord_invite_api: str # 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 91f30ac5e..7386187ab 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -1,6 +1,9 @@ import logging +from datetime import datetime from ssl import CertificateError +from typing import Union +import dateparser import discord from aiohttp import ClientConnectorError from discord.ext.commands import BadArgument, Context, Converter @@ -13,17 +16,16 @@ class ValidPythonIdentifier(Converter): """ A converter that checks whether the given string is a valid Python identifier. - This is used to have package names - that correspond to how you would use - the package in your code, e.g. - `import package`. Raises `BadArgument` - if the argument is not a valid Python - identifier, and simply passes through + This is used to have package names that correspond to how you would use the package in your + code, e.g. `import package`. + + Raises `BadArgument` if the argument is not a valid Python identifier, and simply passes through the given argument otherwise. """ @staticmethod - async def convert(ctx, argument: str): + async def convert(ctx: Context, argument: str) -> str: + """Checks whether the given string is a valid Python identifier.""" if not argument.isidentifier(): raise BadArgument(f"`{argument}` is not a valid Python identifier") return argument @@ -33,14 +35,15 @@ class ValidURL(Converter): """ Represents a valid webpage URL. - This converter checks whether the given - URL can be reached and requesting it returns - a status code of 200. If not, `BadArgument` - is raised. Otherwise, it simply passes through the given URL. + This converter checks whether the given URL can be reached and requesting it returns a status + code of 200. If not, `BadArgument` is raised. + + Otherwise, it simply passes through the given URL. """ @staticmethod - async def convert(ctx, url: str): + async def convert(ctx: Context, url: str) -> str: + """This converter checks whether the given URL can be reached with a status code of 200.""" try: async with ctx.bot.http_session.get(url) as resp: if resp.status != 200: @@ -61,26 +64,28 @@ class ValidURL(Converter): class InfractionSearchQuery(Converter): - """ - A converter that checks if the argument is a Discord user, and if not, falls back to a string. - """ + """A converter that checks if the argument is a Discord user, and if not, falls back to a string.""" @staticmethod - async def convert(ctx, arg): + async def convert(ctx: Context, arg: str) -> Union[discord.Member, str]: + """Check if the argument is a Discord user, and if not, falls back to a string.""" try: maybe_snowflake = arg.strip("<@!>") - return await ctx.bot.get_user_info(maybe_snowflake) + return await ctx.bot.fetch_user(maybe_snowflake) except (discord.NotFound, discord.HTTPException): return arg class Subreddit(Converter): - """ - Forces a string to begin with "r/" and checks if it's a valid subreddit. - """ + """Forces a string to begin with "r/" and checks if it's a valid subreddit.""" @staticmethod - async def convert(ctx, sub: str): + async def convert(ctx: Context, sub: str) -> str: + """ + Force sub to begin with "r/" and check if it's a valid subreddit. + + If sub is a valid subreddit, return it prepended with "r/" + """ sub = sub.lower() if not sub.startswith("r/"): @@ -101,9 +106,21 @@ class Subreddit(Converter): class TagNameConverter(Converter): + """ + Ensure that a proposed tag name is valid. + + Valid tag names meet the following conditions: + * All ASCII characters + * Has at least one non-whitespace character + * Not solely numeric + * Shorter than 127 characters + """ + @staticmethod - async def convert(ctx: Context, tag_name: str): - def is_number(value): + async def convert(ctx: Context, tag_name: str) -> str: + """Lowercase & strip whitespace from proposed tag_name & ensure it's valid.""" + def is_number(value: str) -> bool: + """Check to see if the input string is numeric.""" try: float(value) except ValueError: @@ -140,8 +157,15 @@ class TagNameConverter(Converter): class TagContentConverter(Converter): + """Ensure proposed tag content is not empty and contains at least one non-whitespace character.""" + @staticmethod - async def convert(ctx: Context, tag_content: str): + async def convert(ctx: Context, tag_content: str) -> str: + """ + Ensure tag_content is non-empty and contains at least one non-whitespace character. + + If tag_content is valid, return the stripped version. + """ tag_content = tag_content.strip() # The tag contents should not be empty, or filled with whitespace. @@ -151,3 +175,25 @@ class TagContentConverter(Converter): raise BadArgument("Tag contents should not be empty, or filled with whitespace.") return tag_content + + +class ExpirationDate(Converter): + """Convert relative expiration date into UTC datetime using dateparser.""" + + DATEPARSER_SETTINGS = { + 'PREFER_DATES_FROM': 'future', + 'TIMEZONE': 'UTC', + 'TO_TIMEZONE': 'UTC' + } + + async def convert(self, ctx: Context, expiration_string: str) -> datetime: + """Convert relative expiration date into UTC datetime.""" + expiry = dateparser.parse(expiration_string, settings=self.DATEPARSER_SETTINGS) + if expiry is None: + raise BadArgument(f"Failed to parse expiration date from `{expiration_string}`") + + now = datetime.utcnow() + if expiry < now: + expiry = now + (now - expiry) + + return expiry diff --git a/bot/decorators.py b/bot/decorators.py index 1ba2cd59e..33a6bcadd 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -1,9 +1,9 @@ import logging import random -import typing from asyncio import Lock, sleep from contextlib import suppress from functools import wraps +from typing import Any, Callable, Container, Optional from weakref import WeakValueDictionary from discord import Colour, Embed @@ -18,14 +18,19 @@ log = logging.getLogger(__name__) class InChannelCheckFailure(CheckFailure): - pass + """Raised when a check fails for a message being sent in a whitelisted channel.""" + def __init__(self, *channels: int): + self.channels = channels + channels_str = ', '.join(f"<#{c_id}>" for c_id in channels) -def in_channel(*channels: int, bypass_roles: typing.Container[int] = None): - """ - Checks that the message is in a whitelisted channel or optionally has a bypass role. - """ - def predicate(ctx: Context): + 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: + """Checks that the message is in a whitelisted channel or optionally has a bypass role.""" + def predicate(ctx: Context) -> bool: + """In-channel checker predicate.""" if ctx.channel.id in channels: log.debug(f"{ctx.author} tried to call the '{ctx.command.name}' command. " f"The command was used in a whitelisted channel.") @@ -41,50 +46,39 @@ def in_channel(*channels: int, bypass_roles: typing.Container[int] = None): 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) -def with_role(*role_ids: int): - """ - Returns True if the user has any one - of the roles in role_ids. - """ - - async def predicate(ctx: Context): +def with_role(*role_ids: int) -> Callable: + """Returns True if the user has any one of the roles in role_ids.""" + async def predicate(ctx: Context) -> bool: + """With role checker predicate.""" return with_role_check(ctx, *role_ids) return commands.check(predicate) -def without_role(*role_ids: int): - """ - Returns True if the user does not have any - of the roles in role_ids. - """ - - async def predicate(ctx: Context): +def without_role(*role_ids: int) -> Callable: + """Returns True if the user does not have any of the roles in role_ids.""" + async def predicate(ctx: Context) -> bool: return without_role_check(ctx, *role_ids) return commands.check(predicate) -def locked(): +def locked() -> Callable: """ Allows the user to only run one instance of the decorated command at a time. - Subsequent calls to the command from the same author are - ignored until the command has completed invocation. + + Subsequent calls to the command from the same author are ignored until the command has completed invocation. This decorator has to go before (below) the `command` decorator. """ - - def wrap(func): + def wrap(func: Callable) -> Callable: func.__locks = WeakValueDictionary() @wraps(func) - async def inner(self, ctx, *args, **kwargs): + async def inner(self: Callable, ctx: Context, *args, **kwargs) -> Optional[Any]: lock = func.__locks.setdefault(ctx.author.id, Lock()) if lock.locked(): embed = Embed() @@ -104,15 +98,15 @@ def locked(): return wrap -def redirect_output(destination_channel: int, bypass_roles: typing.Container[int] = None): - """ - Changes the channel in the context of the command to redirect the output - to a certain channel, unless the author has a role to bypass redirection +def redirect_output(destination_channel: int, bypass_roles: Container[int] = None) -> Callable: """ + Changes the channel in the context of the command to redirect the output to a certain channel. - def wrap(func): + Redirect is bypassed if the author has a role to bypass redirection. + """ + def wrap(func: Callable) -> Callable: @wraps(func) - async def inner(self, ctx, *args, **kwargs): + async def inner(self: Callable, ctx: Context, *args, **kwargs) -> Any: if ctx.channel.id == destination_channel: log.trace(f"Command {ctx.command.name} was invoked in destination_channel, not redirecting") return await func(self, ctx, *args, **kwargs) diff --git a/bot/interpreter.py b/bot/interpreter.py index 06343db39..a42b45a2d 100644 --- a/bot/interpreter.py +++ b/bot/interpreter.py @@ -1,5 +1,8 @@ from code import InteractiveInterpreter from io import StringIO +from typing import Any + +from discord.ext.commands import Bot, Context CODE_TEMPLATE = """ async def _func(): @@ -8,13 +11,20 @@ async def _func(): class Interpreter(InteractiveInterpreter): + """ + Subclass InteractiveInterpreter to specify custom run functionality. + + Helper class for internal eval. + """ + write_callable = None - def __init__(self, bot): + def __init__(self, bot: Bot): _locals = {"bot": bot} super().__init__(_locals) - async def run(self, code, ctx, io, *args, **kwargs): + async def run(self, code: str, ctx: Context, io: StringIO, *args, **kwargs) -> Any: + """Execute the provided source code as the bot & return the output.""" self.locals["_rvalue"] = [] self.locals["ctx"] = ctx self.locals["print"] = lambda x: io.write(f"{x}\n") diff --git a/bot/pagination.py b/bot/pagination.py index 0ad5b81f1..76082f459 100644 --- a/bot/pagination.py +++ b/bot/pagination.py @@ -2,7 +2,7 @@ import asyncio import logging from typing import Iterable, List, Optional, Tuple -from discord import Embed, Member, Reaction +from discord import Embed, Member, Message, Reaction from discord.abc import User from discord.ext.commands import Context, Paginator @@ -18,6 +18,8 @@ log = logging.getLogger(__name__) class EmptyPaginatorEmbed(Exception): + """Raised when attempting to paginate with empty contents.""" + pass @@ -25,25 +27,24 @@ class LinePaginator(Paginator): """ A class that aids in paginating code blocks for Discord messages. - Attributes - ----------- - prefix: :class:`str` + Available attributes include: + * prefix: `str` The prefix inserted to every page. e.g. three backticks. - suffix: :class:`str` + * suffix: `str` The suffix appended at the end of every page. e.g. three backticks. - max_size: :class:`int` + * max_size: `int` The maximum amount of codepoints allowed in a page. - max_lines: :class:`int` + * max_lines: `int` The maximum amount of lines allowed in a page. """ - def __init__(self, prefix='```', suffix='```', - max_size=2000, max_lines=None): + def __init__( + self, prefix: str = '```', suffix: str = '```', max_size: int = 2000, max_lines: int = None + ) -> None: """ - This function overrides the Paginator.__init__ - from inside discord.ext.commands. - It overrides in order to allow us to configure - the maximum number of lines per page. + This function overrides the Paginator.__init__ from inside discord.ext.commands. + + It overrides in order to allow us to configure the maximum number of lines per page. """ self.prefix = prefix self.suffix = suffix @@ -54,28 +55,15 @@ class LinePaginator(Paginator): self._count = len(prefix) + 1 # prefix + newline self._pages = [] - def add_line(self, line='', *, empty=False): - """Adds a line to the current page. - - If the line exceeds the :attr:`max_size` then an exception - is raised. + def add_line(self, line: str = '', *, empty: bool = False) -> None: + """ + Adds a line to the current page. - This function overrides the Paginator.add_line - from inside discord.ext.commands. - It overrides in order to allow us to configure - the maximum number of lines per page. + If the line exceeds the `self.max_size` then an exception is raised. - Parameters - ----------- - line: str - The line to add. - empty: bool - Indicates if another empty line should be added. + This function overrides the `Paginator.add_line` from inside `discord.ext.commands`. - Raises - ------ - RuntimeError - The line was too big for the current :attr:`max_size`. + It overrides in order to allow us to configure the maximum number of lines per page. """ if len(line) > self.max_size - len(self.prefix) - 2: raise RuntimeError('Line exceeds maximum page size %s' % (self.max_size - len(self.prefix) - 2)) @@ -97,42 +85,39 @@ class LinePaginator(Paginator): self._count += 1 @classmethod - async def paginate(cls, lines: Iterable[str], ctx: Context, embed: Embed, - prefix: str = "", suffix: str = "", max_lines: Optional[int] = None, max_size: int = 500, - empty: bool = True, restrict_to_user: User = None, timeout: int = 300, - footer_text: str = None, url: str = None, exception_on_empty_embed: bool = False): + async def paginate( + cls, + lines: Iterable[str], + ctx: Context, + embed: Embed, + prefix: str = "", + suffix: str = "", + max_lines: Optional[int] = None, + max_size: int = 500, + empty: bool = True, + restrict_to_user: User = None, + timeout: int = 300, + footer_text: str = None, + url: str = None, + exception_on_empty_embed: bool = False + ) -> Optional[Message]: """ - Use a paginator and set of reactions to provide pagination over a set of lines. The reactions are used to - switch page, or to finish with pagination. + Use a paginator and set of reactions to provide pagination over a set of lines. + + The reactions are used to switch page, or to finish with pagination. + When used, this will send a message using `ctx.send()` and apply a set of reactions to it. These reactions may - be used to change page, or to remove pagination from the message. Pagination will also be removed automatically - if no reaction is added for five minutes (300 seconds). + be used to change page, or to remove pagination from the message. + + Pagination will also be removed automatically if no reaction is added for five minutes (300 seconds). + + Example: >>> embed = Embed() >>> embed.set_author(name="Some Operation", url=url, icon_url=icon) - >>> await LinePaginator.paginate( - ... (line for line in lines), - ... ctx, embed - ... ) - :param lines: The lines to be paginated - :param ctx: Current context object - :param embed: A pre-configured embed to be used as a template for each page - :param prefix: Text to place before each page - :param suffix: Text to place after each page - :param max_lines: The maximum number of lines on each page - :param max_size: The maximum number of characters on each page - :param empty: Whether to place an empty line between each given line - :param restrict_to_user: A user to lock pagination operations to for this message, if supplied - :param exception_on_empty_embed: Should there be an exception if the embed is empty? - :param url: the url to use for the embed headline - :param timeout: The amount of time in seconds to disable pagination of no reaction is added - :param footer_text: Text to prefix the page number in the footer with + >>> await LinePaginator.paginate((line for line in lines), ctx, embed) """ - - def event_check(reaction_: Reaction, user_: Member): - """ - Make sure that this reaction is what we want to operate on - """ - + def event_check(reaction_: Reaction, user_: Member) -> bool: + """Make sure that this reaction is what we want to operate on.""" no_restrictions = ( # Pagination is not restricted not restrict_to_user @@ -301,24 +286,20 @@ class LinePaginator(Paginator): class ImagePaginator(Paginator): """ Helper class that paginates images for embeds in messages. + Close resemblance to LinePaginator, except focuses on images over text. Refer to ImagePaginator.paginate for documentation on how to use. """ - def __init__(self, prefix="", suffix=""): + def __init__(self, prefix: str = "", suffix: str = ""): super().__init__(prefix, suffix) self._current_page = [prefix] self.images = [] self._pages = [] def add_line(self, line: str = '', *, empty: bool = False) -> None: - """ - Adds a line to each page, usually just 1 line in this context - :param line: str to be page content / title - :param empty: if there should be new lines between entries - """ - + """Adds a line to each page.""" if line: self._count = len(line) else: @@ -327,50 +308,36 @@ class ImagePaginator(Paginator): self.close_page() def add_image(self, image: str = None) -> None: - """ - Adds an image to a page - :param image: image url to be appended - """ - + """Adds an image to a page.""" self.images.append(image) @classmethod - async def paginate(cls, pages: List[Tuple[str, str]], ctx: Context, embed: Embed, - prefix: str = "", suffix: str = "", timeout: int = 300, - exception_on_empty_embed: bool = False): + async def paginate( + cls, + pages: List[Tuple[str, str]], + ctx: Context, embed: Embed, + prefix: str = "", + suffix: str = "", + timeout: int = 300, + exception_on_empty_embed: bool = False + ) -> Optional[Message]: """ - Use a paginator and set of reactions to provide - pagination over a set of title/image pairs.The reactions are - used to switch page, or to finish with pagination. + Use a paginator and set of reactions to provide pagination over a set of title/image pairs. + + The reactions are used to switch page, or to finish with pagination. - When used, this will send a message using `ctx.send()` and - apply a set of reactions to it. These reactions may + When used, this will send a message using `ctx.send()` and apply a set of reactions to it. These reactions may be used to change page, or to remove pagination from the message. - Note: Pagination will be removed automatically - if no reaction is added for five minutes (300 seconds). + Note: Pagination will be removed automatically if no reaction is added for five minutes (300 seconds). + Example: >>> embed = Embed() >>> embed.set_author(name="Some Operation", url=url, icon_url=icon) >>> await ImagePaginator.paginate(pages, ctx, embed) - - Parameters - ----------- - :param pages: An iterable of tuples with title for page, and img url - :param ctx: ctx for message - :param embed: base embed to modify - :param prefix: prefix of message - :param suffix: suffix of message - :param timeout: timeout for when reactions get auto-removed """ - def check_event(reaction_: Reaction, member: Member) -> bool: - """ - Checks each reaction added, if it matches our conditions pass the wait_for - :param reaction_: reaction added - :param member: reaction added by member - """ - + """Checks each reaction added, if it matches our conditions pass the wait_for.""" return all(( # Reaction is on the same message sent reaction_.message.id == message.id, 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..a0154f12d --- /dev/null +++ b/bot/patches/message_edited_at.py @@ -0,0 +1,32 @@ +""" +# message_edited_at patch. + +Date: 2019-09-16 +Author: Scragly +Added by: Ves Zappa + +Due to a bug in our current version of discord.py (1.2.3), the edited_at timestamp of +`discord.Messages` are not being handled correctly. This patch fixes that until a new +release of discord.py is released (and we've updated to it). +""" +import logging + +from discord import message, utils + +log = logging.getLogger(__name__) + + +def _handle_edited_timestamp(self: message.Message, value: str) -> None: + """Helper function that takes care of parsing the edited timestamp.""" + self._edited_timestamp = utils.parse_time(value) + + +def apply_patch() -> None: + """Applies the `edited_at` patch to the `discord.message.Message` class.""" + message.Message._handle_edited_timestamp = _handle_edited_timestamp + message.Message._HANDLERS['edited_timestamp'] = message.Message._handle_edited_timestamp + log.info("Patch applied: message_edited_at") + + +if __name__ == "__main__": + apply_patch() diff --git a/bot/rules/attachments.py b/bot/rules/attachments.py index 47b927101..c550aed76 100644 --- a/bot/rules/attachments.py +++ b/bot/rules/attachments.py @@ -1,24 +1,20 @@ -"""Detects total attachments exceeding the limit sent by a single user.""" - from typing import Dict, Iterable, List, Optional, Tuple from discord import Member, Message async def apply( - last_message: Message, - recent_messages: List[Message], - config: Dict[str, int] + last_message: Message, recent_messages: List[Message], config: Dict[str, int] ) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]: - - relevant_messages = tuple( + """Detects total attachments exceeding the limit sent by a single user.""" + relevant_messages = [last_message] + [ msg for msg in recent_messages if ( msg.author == last_message.author and len(msg.attachments) > 0 ) - ) + ] total_recent_attachments = sum(len(msg.attachments) for msg in relevant_messages) if total_recent_attachments > config['max']: diff --git a/bot/rules/burst.py b/bot/rules/burst.py index 80c79be60..25c5a2f33 100644 --- a/bot/rules/burst.py +++ b/bot/rules/burst.py @@ -1,16 +1,12 @@ -"""Detects repeated messages sent by a single user.""" - from typing import Dict, Iterable, List, Optional, Tuple from discord import Member, Message async def apply( - last_message: Message, - recent_messages: List[Message], - config: Dict[str, int] + last_message: Message, recent_messages: List[Message], config: Dict[str, int] ) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]: - + """Detects repeated messages sent by a single user.""" relevant_messages = tuple( msg for msg in recent_messages diff --git a/bot/rules/burst_shared.py b/bot/rules/burst_shared.py index 2cb7b5200..bbe9271b3 100644 --- a/bot/rules/burst_shared.py +++ b/bot/rules/burst_shared.py @@ -1,16 +1,12 @@ -"""Detects repeated messages sent by multiple users.""" - from typing import Dict, Iterable, List, Optional, Tuple from discord import Member, Message async def apply( - last_message: Message, - recent_messages: List[Message], - config: Dict[str, int] + last_message: Message, recent_messages: List[Message], config: Dict[str, int] ) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]: - + """Detects repeated messages sent by multiple users.""" total_recent = len(recent_messages) if total_recent > config['max']: diff --git a/bot/rules/chars.py b/bot/rules/chars.py index d05e3cd83..1f587422c 100644 --- a/bot/rules/chars.py +++ b/bot/rules/chars.py @@ -1,16 +1,12 @@ -"""Detects total message char count exceeding the limit sent by a single user.""" - from typing import Dict, Iterable, List, Optional, Tuple from discord import Member, Message async def apply( - last_message: Message, - recent_messages: List[Message], - config: Dict[str, int] + last_message: Message, recent_messages: List[Message], config: Dict[str, int] ) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]: - + """Detects total message char count exceeding the limit sent by a single user.""" relevant_messages = tuple( msg for msg in recent_messages diff --git a/bot/rules/discord_emojis.py b/bot/rules/discord_emojis.py index e4f957ddb..5bab514f2 100644 --- a/bot/rules/discord_emojis.py +++ b/bot/rules/discord_emojis.py @@ -1,5 +1,3 @@ -"""Detects total Discord emojis (excluding Unicode emojis) exceeding the limit sent by a single user.""" - import re from typing import Dict, Iterable, List, Optional, Tuple @@ -10,11 +8,9 @@ DISCORD_EMOJI_RE = re.compile(r"<:\w+:\d+>") async def apply( - last_message: Message, - recent_messages: List[Message], - config: Dict[str, int] + last_message: Message, recent_messages: List[Message], config: Dict[str, int] ) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]: - + """Detects total Discord emojis (excluding Unicode emojis) exceeding the limit sent by a single user.""" relevant_messages = tuple( msg for msg in recent_messages diff --git a/bot/rules/duplicates.py b/bot/rules/duplicates.py index 763fc9983..455764b53 100644 --- a/bot/rules/duplicates.py +++ b/bot/rules/duplicates.py @@ -1,16 +1,12 @@ -"""Detects duplicated messages sent by a single user.""" - from typing import Dict, Iterable, List, Optional, Tuple from discord import Member, Message async def apply( - last_message: Message, - recent_messages: List[Message], - config: Dict[str, int] + last_message: Message, recent_messages: List[Message], config: Dict[str, int] ) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]: - + """Detects duplicated messages sent by a single user.""" relevant_messages = tuple( msg for msg in recent_messages diff --git a/bot/rules/links.py b/bot/rules/links.py index fa4043fcb..ec75a19c5 100644 --- a/bot/rules/links.py +++ b/bot/rules/links.py @@ -1,5 +1,3 @@ -"""Detects total links exceeding the limit sent by a single user.""" - import re from typing import Dict, Iterable, List, Optional, Tuple @@ -10,11 +8,9 @@ LINK_RE = re.compile(r"(https?://[^\s]+)") async def apply( - last_message: Message, - recent_messages: List[Message], - config: Dict[str, int] + last_message: Message, recent_messages: List[Message], config: Dict[str, int] ) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]: - + """Detects total links exceeding the limit sent by a single user.""" relevant_messages = tuple( msg for msg in recent_messages diff --git a/bot/rules/mentions.py b/bot/rules/mentions.py index 45c47b6ba..79725a4b1 100644 --- a/bot/rules/mentions.py +++ b/bot/rules/mentions.py @@ -1,16 +1,12 @@ -"""Detects total mentions exceeding the limit sent by a single user.""" - from typing import Dict, Iterable, List, Optional, Tuple from discord import Member, Message async def apply( - last_message: Message, - recent_messages: List[Message], - config: Dict[str, int] + last_message: Message, recent_messages: List[Message], config: Dict[str, int] ) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]: - + """Detects total mentions exceeding the limit sent by a single user.""" relevant_messages = tuple( msg for msg in recent_messages diff --git a/bot/rules/newlines.py b/bot/rules/newlines.py index fdad6ffd3..4e66e1359 100644 --- a/bot/rules/newlines.py +++ b/bot/rules/newlines.py @@ -1,5 +1,3 @@ -"""Detects total newlines exceeding the set limit sent by a single user.""" - import re from typing import Dict, Iterable, List, Optional, Tuple @@ -7,11 +5,9 @@ from discord import Member, Message async def apply( - last_message: Message, - recent_messages: List[Message], - config: Dict[str, int] + last_message: Message, recent_messages: List[Message], config: Dict[str, int] ) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]: - + """Detects total newlines exceeding the set limit sent by a single user.""" relevant_messages = tuple( msg for msg in recent_messages diff --git a/bot/rules/role_mentions.py b/bot/rules/role_mentions.py index 2177a73b5..0649540b6 100644 --- a/bot/rules/role_mentions.py +++ b/bot/rules/role_mentions.py @@ -1,16 +1,12 @@ -"""Detects total role mentions exceeding the limit sent by a single user.""" - from typing import Dict, Iterable, List, Optional, Tuple from discord import Member, Message async def apply( - last_message: Message, - recent_messages: List[Message], - config: Dict[str, int] + last_message: Message, recent_messages: List[Message], config: Dict[str, int] ) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]: - + """Detects total role mentions exceeding the limit sent by a single user.""" relevant_messages = tuple( msg for msg in recent_messages diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py index 4c99d50e8..8184be824 100644 --- a/bot/utils/__init__.py +++ b/bot/utils/__init__.py @@ -1,3 +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): """ @@ -7,50 +18,59 @@ class CaseInsensitiveDict(dict): """ @classmethod - def _k(cls, key): + def _k(cls, key: Hashable) -> Hashable: + """Return lowered key if a string-like is passed, otherwise pass key straight through.""" return key.lower() if isinstance(key, str) else key def __init__(self, *args, **kwargs): super(CaseInsensitiveDict, self).__init__(*args, **kwargs) self._convert_keys() - def __getitem__(self, key): + def __getitem__(self, key: Hashable) -> Any: + """Case insensitive __setitem__.""" return super(CaseInsensitiveDict, self).__getitem__(self.__class__._k(key)) - def __setitem__(self, key, value): + def __setitem__(self, key: Hashable, value: Any): + """Case insensitive __setitem__.""" super(CaseInsensitiveDict, self).__setitem__(self.__class__._k(key), value) - def __delitem__(self, key): + def __delitem__(self, key: Hashable) -> Any: + """Case insensitive __delitem__.""" return super(CaseInsensitiveDict, self).__delitem__(self.__class__._k(key)) - def __contains__(self, key): + def __contains__(self, key: Hashable) -> bool: + """Case insensitive __contains__.""" return super(CaseInsensitiveDict, self).__contains__(self.__class__._k(key)) - def pop(self, key, *args, **kwargs): + def pop(self, key: Hashable, *args, **kwargs) -> Any: + """Case insensitive pop.""" return super(CaseInsensitiveDict, self).pop(self.__class__._k(key), *args, **kwargs) - def get(self, key, *args, **kwargs): + def get(self, key: Hashable, *args, **kwargs) -> Any: + """Case insensitive get.""" return super(CaseInsensitiveDict, self).get(self.__class__._k(key), *args, **kwargs) - def setdefault(self, key, *args, **kwargs): + def setdefault(self, key: Hashable, *args, **kwargs) -> Any: + """Case insensitive setdefault.""" return super(CaseInsensitiveDict, self).setdefault(self.__class__._k(key), *args, **kwargs) - def update(self, E=None, **F): + def update(self, E: Any = None, **F) -> None: + """Case insensitive update.""" super(CaseInsensitiveDict, self).update(self.__class__(E)) super(CaseInsensitiveDict, self).update(self.__class__(**F)) - def _convert_keys(self): + def _convert_keys(self) -> None: + """Helper method to lowercase all existing string-like keys.""" for k in list(self.keys()): v = super(CaseInsensitiveDict, self).pop(k) self.__setitem__(k, v) -def chunks(iterable, size): +def chunks(iterable: Iterable, size: int) -> Generator[Any, None, None]: """ - Generator that allows you to iterate over any indexable collection in `size`-length chunks + Generator that allows you to iterate over any indexable collection in `size`-length chunks. Found: https://stackoverflow.com/a/312464/4022104 """ - for i in range(0, len(iterable), size): yield iterable[i:i + size] diff --git a/bot/utils/checks.py b/bot/utils/checks.py index 37dc657f7..19f64ff9f 100644 --- a/bot/utils/checks.py +++ b/bot/utils/checks.py @@ -6,11 +6,7 @@ log = logging.getLogger(__name__) def with_role_check(ctx: Context, *role_ids: int) -> bool: - """ - Returns True if the user has any one - of the roles in role_ids. - """ - + """Returns True if the user has any one of the roles in role_ids.""" if not ctx.guild: # Return False in a DM log.trace(f"{ctx.author} tried to use the '{ctx.command.name}'command from a DM. " "This command is restricted by the with_role decorator. Rejecting request.") @@ -27,17 +23,13 @@ def with_role_check(ctx: Context, *role_ids: int) -> bool: def without_role_check(ctx: Context, *role_ids: int) -> bool: - """ - Returns True if the user does not have any - of the roles in role_ids. - """ - + """Returns True if the user does not have any of the roles in role_ids.""" if not ctx.guild: # Return False in a DM log.trace(f"{ctx.author} tried to use the '{ctx.command.name}' command from a DM. " "This command is restricted by the without_role decorator. Rejecting request.") 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}.") @@ -45,11 +37,7 @@ def without_role_check(ctx: Context, *role_ids: int) -> bool: def in_channel_check(ctx: Context, channel_id: int) -> bool: - """ - Checks if the command was executed - inside of the specified channel. - """ - + """Checks if the command was executed inside of the specified channel.""" check = ctx.channel.id == channel_id log.trace(f"{ctx.author} tried to call the '{ctx.command.name}' command. " f"The result of the in_channel check was {check}.") diff --git a/bot/utils/messages.py b/bot/utils/messages.py index fc38b0127..549b33ca6 100644 --- a/bot/utils/messages.py +++ b/bot/utils/messages.py @@ -1,9 +1,9 @@ import asyncio import contextlib from io import BytesIO -from typing import Sequence +from typing import Optional, Sequence, Union -from discord import Embed, File, Message, TextChannel +from discord import Client, Embed, File, Member, Message, Reaction, TextChannel, Webhook from discord.abc import Snowflake from discord.errors import HTTPException @@ -17,42 +17,18 @@ async def wait_for_deletion( user_ids: Sequence[Snowflake], deletion_emojis: Sequence[str] = (Emojis.cross_mark,), timeout: float = 60 * 5, - attach_emojis=True, - client=None -): - """ - Waits for up to `timeout` seconds for a reaction by - any of the specified `user_ids` to delete the message. - - Args: - message (Message): - The message that should be monitored for reactions - and possibly deleted. Must be a message sent on a - guild since access to the bot instance is required. - - user_ids (Sequence[Snowflake]): - A sequence of users that are allowed to delete - this message. - - Kwargs: - deletion_emojis (Sequence[str]): - A sequence of emojis that are considered deletion - emojis. - - timeout (float): - A positive float denoting the maximum amount of - time to wait for a deletion reaction. - - attach_emojis (bool): - Whether to attach the given `deletion_emojis` - to the message in the given `context` - - client (Optional[discord.Client]): - The client instance handling the original command. - If not given, will take the client from the guild - of the message. + attach_emojis: bool = True, + client: Optional[Client] = None +) -> None: """ + Wait for up to `timeout` seconds for a reaction by any of the specified `user_ids` to delete the message. + + An `attach_emojis` bool may be specified to determine whether to attach the given + `deletion_emojis` to the message in the given `context` + A `client` instance may be optionally specified, otherwise client will be taken from the + guild of the message. + """ if message.guild is None and client is None: raise ValueError("Message must be sent on a guild") @@ -62,7 +38,8 @@ async def wait_for_deletion( for emoji in deletion_emojis: await message.add_reaction(emoji) - def check(reaction, user): + def check(reaction: Reaction, user: Member) -> bool: + """Check that the deletion emoji is reacted by the approprite user.""" return ( reaction.message.id == message.id and reaction.emoji in deletion_emojis @@ -70,25 +47,17 @@ async def wait_for_deletion( ) with contextlib.suppress(asyncio.TimeoutError): - await bot.wait_for( - 'reaction_add', - check=check, - timeout=timeout - ) + await bot.wait_for('reaction_add', check=check, timeout=timeout) await message.delete() -async def send_attachments(message: Message, destination: TextChannel): +async def send_attachments(message: Message, destination: Union[TextChannel, Webhook]) -> None: """ - Re-uploads each attachment in a message to the given channel. + Re-uploads each attachment in a message to the given channel or webhook. Each attachment is sent as a separate message to more easily comply with the 8 MiB request size limit. If attachments are too large, they are instead grouped into a single embed which links to them. - - :param message: the message whose attachments to re-upload - :param destination: the channel in which to re-upload the attachments """ - large = [] for attachment in message.attachments: try: @@ -97,7 +66,16 @@ async def send_attachments(message: Message, destination: TextChannel): if attachment.size <= MAX_SIZE - 512: with BytesIO() as file: await attachment.save(file) - await destination.send(file=File(file, filename=attachment.filename)) + attachment_file = File(file, filename=attachment.filename) + + if isinstance(destination, TextChannel): + await destination.send(file=attachment_file) + else: + await destination.send( + file=attachment_file, + username=message.author.display_name, + avatar_url=message.author.avatar_url + ) else: large.append(attachment) except HTTPException as e: @@ -109,4 +87,11 @@ async def send_attachments(message: Message, destination: TextChannel): if large: embed = Embed(description=f"\n".join(f"[{attachment.filename}]({attachment.url})" for attachment in large)) embed.set_footer(text="Attachments exceed upload size limit.") - await destination.send(embed=embed) + if isinstance(destination, TextChannel): + await destination.send(embed=embed) + else: + await destination.send( + embed=embed, + username=message.author.display_name, + avatar_url=message.author.avatar_url + ) diff --git a/bot/utils/moderation.py b/bot/utils/moderation.py index 724b455bc..7860f14a1 100644 --- a/bot/utils/moderation.py +++ b/bot/utils/moderation.py @@ -1,11 +1,12 @@ import logging -from typing import Union +from datetime import datetime +from typing import Optional, Union -from aiohttp import ClientError from discord import Member, Object, User from discord.ext.commands import Context -from bot.constants import Keys, URLs +from bot.api import ResponseCodeError +from bot.constants import Keys log = logging.getLogger(__name__) @@ -13,33 +14,59 @@ HEADERS = {"X-API-KEY": Keys.site_api} async def post_infraction( - ctx: Context, user: Union[Member, Object, User], type: str, reason: str, duration: str = None, hidden: bool = False -): - + ctx: Context, + user: Union[Member, Object, User], + type: str, + reason: str, + expires_at: datetime = None, + hidden: bool = False, + active: bool = True, +) -> Optional[dict]: + """Posts an infraction to the API.""" payload = { - "type": type, + "actor": ctx.message.author.id, + "hidden": hidden, "reason": reason, - "user_id": str(user.id), - "actor_id": str(ctx.message.author.id), - "hidden": hidden + "type": type, + "user": user.id, + "active": active } - if duration: - payload['duration'] = duration + if expires_at: + payload['expires_at'] = expires_at.isoformat() try: - response = await ctx.bot.http_session.post( - URLs.site_infractions, - headers=HEADERS, - 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 + response = await ctx.bot.api_client.post('bot/infractions', json=payload) + 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 - response_object = await response.json() - if "error_code" in response_object: - await ctx.send(f":x: There was an error adding the infraction: {response_object['error_message']}") - return - return response_object +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 ded6401b0..08abd91d7 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -1,13 +1,16 @@ import asyncio import contextlib import logging -from abc import ABC, abstractmethod -from typing import Dict +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): @@ -15,24 +18,23 @@ class Scheduler(ABC): self.scheduled_tasks: Dict[str, asyncio.Task] = {} @abstractmethod - async def _scheduled_task(self, task_object: dict): + async def _scheduled_task(self, task_object: dict) -> None: """ - A coroutine which handles the scheduling. This is added to the scheduled tasks, - and should wait the task duration, execute the desired code, and clean up the task. + A coroutine which handles the scheduling. + + This is added to the scheduled tasks, and should wait the task duration, execute the desired + code, then clean up the task. + For example, in Reminders this will wait for the reminder duration, send the reminder, then make a site API request to delete the reminder from the database. - - :param task_object: """ - def schedule_task(self, loop: asyncio.AbstractEventLoop, task_id: str, task_data: dict): + def schedule_task(self, loop: asyncio.AbstractEventLoop, task_id: str, task_data: dict) -> None: """ Schedules a task. - :param loop: the asyncio event loop - :param task_id: the ID of the task. - :param task_data: the data of the task, passed to `Scheduler._scheduled_expiration`. - """ + `task_data` is passed to `Scheduler._scheduled_expiration` + """ if task_id in self.scheduled_tasks: return @@ -40,12 +42,8 @@ class Scheduler(ABC): self.scheduled_tasks[task_id] = task - def cancel_task(self, task_id: str): - """ - Un-schedules a task. - :param task_id: the ID of the infraction in question - """ - + def cancel_task(self, task_id: str) -> None: + """Un-schedules a task.""" task = self.scheduled_tasks.get(task_id) if task is None: @@ -57,14 +55,8 @@ class Scheduler(ABC): del self.scheduled_tasks[task_id] -def create_task(loop: asyncio.AbstractEventLoop, coro_or_future): - """ - Creates an asyncio.Task object from a coroutine or future object. - - :param loop: the asyncio event loop. - :param coro_or_future: the coroutine or future object to be scheduled. - """ - +def create_task(loop: asyncio.AbstractEventLoop, coro_or_future: Union[Coroutine, asyncio.Future]) -> asyncio.Task: + """Creates an asyncio.Task object from a coroutine or future object.""" task: asyncio.Task = asyncio.ensure_future(coro_or_future, loop=loop) # Silently ignore exceptions in a callback (handles the CancelledError nonsense) @@ -72,6 +64,7 @@ def create_task(loop: asyncio.AbstractEventLoop, coro_or_future): return task -def _silent_exception(future): +def _silent_exception(future: asyncio.Future) -> None: + """Suppress future's exception.""" with contextlib.suppress(Exception): future.exception() diff --git a/bot/utils/service_discovery.py b/bot/utils/service_discovery.py deleted file mode 100644 index 8d79096bd..000000000 --- a/bot/utils/service_discovery.py +++ /dev/null @@ -1,22 +0,0 @@ -import datetime -import socket -import time -from contextlib import closing - -from bot.constants import RabbitMQ - -THIRTY_SECONDS = datetime.timedelta(seconds=30) - - -def wait_for_rmq(): - start = datetime.datetime.now() - - while True: - if datetime.datetime.now() - start > THIRTY_SECONDS: - return False - - with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as sock: - if sock.connect_ex((RabbitMQ.host, RabbitMQ.port)) == 0: - return True - - time.sleep(0.5) diff --git a/bot/utils/time.py b/bot/utils/time.py index 8e5d4e1bd..c529ccc2b 100644 --- a/bot/utils/time.py +++ b/bot/utils/time.py @@ -6,10 +6,9 @@ from dateutil.relativedelta import relativedelta RFC1123_FORMAT = "%a, %d %b %Y %H:%M:%S GMT" -def _stringify_time_unit(value: int, unit: str): +def _stringify_time_unit(value: int, unit: str) -> str: """ - Returns a string to represent a value and time unit, - ensuring that it uses the right plural form of the unit. + Returns a string to represent a value and time unit, ensuring that it uses the right plural form of the unit. >>> _stringify_time_unit(1, "seconds") "1 second" @@ -18,7 +17,6 @@ def _stringify_time_unit(value: int, unit: str): >>> _stringify_time_unit(0, "minutes") "less than a minute" """ - if value == 1: return f"{value} {unit[:-1]}" elif value == 0: @@ -27,18 +25,13 @@ def _stringify_time_unit(value: int, unit: str): return f"{value} {unit}" -def humanize_delta(delta: relativedelta, precision: str = "seconds", max_units: int = 6): +def humanize_delta(delta: relativedelta, precision: str = "seconds", max_units: int = 6) -> str: """ Returns a human-readable version of the relativedelta. - :param delta: A dateutil.relativedelta.relativedelta object - :param precision: The smallest unit that should be included. - :param max_units: The maximum number of time-units to return. - - :return: A string like `4 days, 12 hours and 1 second`, - `1 minute`, or `less than a minute`. + precision specifies the smallest unit of time to include (e.g. "seconds", "minutes"). + max_units specifies the maximum number of units of time to include (e.g. 1 may include days but not hours). """ - units = ( ("years", delta.years), ("months", delta.months), @@ -73,19 +66,13 @@ def humanize_delta(delta: relativedelta, precision: str = "seconds", max_units: return humanized -def time_since(past_datetime: datetime.datetime, precision: str = "seconds", max_units: int = 6): +def time_since(past_datetime: datetime.datetime, precision: str = "seconds", max_units: int = 6) -> str: """ - Takes a datetime and returns a human-readable string that - describes how long ago that datetime was. + Takes a datetime and returns a human-readable string that describes how long ago that datetime was. - :param past_datetime: A datetime.datetime object - :param precision: The smallest unit that should be included. - :param max_units: The maximum number of time-units to return. - - :return: A string like `4 days, 12 hours and 1 second ago`, - `1 minute ago`, or `less than a minute ago`. + precision specifies the smallest unit of time to include (e.g. "seconds", "minutes"). + max_units specifies the maximum number of units of time to include (e.g. 1 may include days but not hours). """ - now = datetime.datetime.utcnow() delta = abs(relativedelta(now, past_datetime)) @@ -94,20 +81,17 @@ def time_since(past_datetime: datetime.datetime, precision: str = "seconds", max return f"{humanized} ago" -def parse_rfc1123(time_str): +def parse_rfc1123(time_str: str) -> datetime.datetime: + """Parse RFC1123 time string into datetime.""" return datetime.datetime.strptime(time_str, RFC1123_FORMAT).replace(tzinfo=datetime.timezone.utc) # Hey, this could actually be used in the off_topic_names and reddit cogs :) -async def wait_until(time: datetime.datetime): - """ - Wait until a given time. - - :param time: A datetime.datetime object to wait until. - """ - - delay = time - datetime.datetime.now(tz=datetime.timezone.utc) +async def wait_until(time: datetime.datetime) -> None: + """Wait until a given time.""" + delay = time - datetime.datetime.utcnow() delay_seconds = delay.total_seconds() + # Incorporate a small delay so we don't rapid-fire the event due to time precision errors if delay_seconds > 1.0: await asyncio.sleep(delay_seconds) diff --git a/config-default.yml b/config-default.yml index af0621ece..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 @@ -136,6 +137,10 @@ guild: rockstars: &ROCKSTARS_ROLE 458226413825294336 team_leader: 501324292341104650 + webhooks: + talent_pool: 569145364800602132 + big_brother: 569133704568373283 + filter: @@ -167,6 +172,7 @@ filter: - 327254708534116352 # Adafruit - 544525886180032552 # kennethreitz.org - 590806733924859943 # Discord Hack Week + - 423249981340778496 # Kivy domain_blacklist: - pornhub.com @@ -217,28 +223,19 @@ 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" -rabbitmq: - host: "pdrmq" - password: !ENV ["RABBITMQ_DEFAULT_PASS", "guest"] - port: 5672 - username: !ENV ["RABBITMQ_DEFAULT_USER", "guest"] - - urls: # PyDis site vars site: &DOMAIN "pythondiscord.com" site_api: &API !JOIN ["api.", *DOMAIN] site_paste: &PASTE !JOIN ["paste.", *DOMAIN] + site_staff: &STAFF !JOIN ["staff.", *DOMAIN] site_schema: &SCHEMA "https://" site_bigbrother_api: !JOIN [*SCHEMA, *API, "/bot/bigbrother"] @@ -251,7 +248,7 @@ urls: site_infractions_user_type_current: !JOIN [*SCHEMA, *API, "/bot/infractions/user/{user_id}/{infraction_type}/current"] site_infractions_user_type: !JOIN [*SCHEMA, *API, "/bot/infractions/user/{user_id}/{infraction_type}"] site_logs_api: !JOIN [*SCHEMA, *API, "/bot/logs"] - site_logs_view: !JOIN [*SCHEMA, *DOMAIN, "/bot/logs"] + site_logs_view: !JOIN [*SCHEMA, *STAFF, "/bot/logs"] site_off_topic_names_api: !JOIN [*SCHEMA, *API, "/bot/off-topic-names"] site_reminders_api: !JOIN [*SCHEMA, *API, "/bot/reminders"] site_reminders_user_api: !JOIN [*SCHEMA, *API, "/bot/reminders/user"] @@ -261,9 +258,8 @@ urls: site_user_complete_api: !JOIN [*SCHEMA, *API, "/bot/users/complete"] paste_service: !JOIN [*SCHEMA, *PASTE, "/{key}"] - # Env vars - deploy: !ENV "DEPLOY_URL" - status: !ENV "STATUS_URL" + # Snekbox + snekbox_eval_api: "https://snekbox.pythondiscord.com/eval" # Discord API URLs discord_api: &DISCORD_API "https://discordapp.com/api/v7/" @@ -271,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/docker/base.Dockerfile b/docker/base.Dockerfile deleted file mode 100644 index e46db756a..000000000 --- a/docker/base.Dockerfile +++ /dev/null @@ -1,17 +0,0 @@ -FROM python:3.6-alpine3.7 - -RUN apk add --update tini -RUN apk add --update build-base -RUN apk add --update libffi-dev -RUN apk add --update zlib -RUN apk add --update jpeg-dev -RUN apk add --update libxml2 libxml2-dev libxslt-dev -RUN apk add --update zlib-dev -RUN apk add --update freetype-dev -RUN apk add --update git - -ENV LIBRARY_PATH=/lib:/usr/lib -ENV PIPENV_VENV_IN_PROJECT=1 -ENV PIPENV_IGNORE_VIRTUALENVS=1 -ENV PIPENV_NOSPIN=1 -ENV PIPENV_HIDE_EMOJIS=1 diff --git a/docker/bot.Dockerfile b/docker/bot.Dockerfile deleted file mode 100644 index 5a07a612b..000000000 --- a/docker/bot.Dockerfile +++ /dev/null @@ -1,17 +0,0 @@ -FROM pythondiscord/bot-base:latest - -ENV PIPENV_VENV_IN_PROJECT=1 -ENV PIPENV_IGNORE_VIRTUALENVS=1 -ENV PIPENV_NOSPIN=1 -ENV PIPENV_HIDE_EMOJIS=1 - -RUN pip install -U pipenv - -RUN mkdir -p /bot -COPY . /bot -WORKDIR /bot - -RUN pipenv install --deploy - -ENTRYPOINT ["/sbin/tini", "--"] -CMD ["pipenv", "run", "start"] diff --git a/scripts/deploy-azure.sh b/scripts/deploy-azure.sh index 6b3dea508..ed4b719e2 100644 --- a/scripts/deploy-azure.sh +++ b/scripts/deploy-azure.sh @@ -4,28 +4,9 @@ cd .. # Build and deploy on master branch, only if not a pull request if [[ ($BUILD_SOURCEBRANCHNAME == 'master') && ($SYSTEM_PULLREQUEST_PULLREQUESTID == '') ]]; then - 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 . + docker build -t pythondiscord/bot:latest . echo "Pushing image" docker push pythondiscord/bot:latest - - echo "Deploying container" - curl -H "token: $1" $2 -else - echo "Skipping deploy" -fi
\ No newline at end of file +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/__init__.py b/tests/__init__.py new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/tests/__init__.py diff --git a/tests/cogs/__init__.py b/tests/cogs/__init__.py new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/tests/cogs/__init__.py diff --git a/tests/cogs/sync/__init__.py b/tests/cogs/sync/__init__.py new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/tests/cogs/sync/__init__.py diff --git a/tests/cogs/sync/test_roles.py b/tests/cogs/sync/test_roles.py new file mode 100644 index 000000000..c561ba447 --- /dev/null +++ b/tests/cogs/sync/test_roles.py @@ -0,0 +1,103 @@ +from bot.cogs.sync.syncers import Role, get_roles_for_sync + + +def test_get_roles_for_sync_empty_return_for_equal_roles(): + api_roles = {Role(id=41, name='name', colour=33, permissions=0x8, position=1)} + guild_roles = {Role(id=41, name='name', colour=33, permissions=0x8, position=1)} + + assert get_roles_for_sync(guild_roles, api_roles) == (set(), set(), set()) + + +def test_get_roles_for_sync_returns_roles_to_update_with_non_id_diff(): + api_roles = {Role(id=41, name='old name', colour=35, permissions=0x8, position=1)} + guild_roles = {Role(id=41, name='new name', colour=33, permissions=0x8, position=2)} + + assert get_roles_for_sync(guild_roles, api_roles) == ( + set(), + guild_roles, + set(), + ) + + +def test_get_roles_only_returns_roles_that_require_update(): + api_roles = { + Role(id=41, name='old name', colour=33, permissions=0x8, position=1), + Role(id=53, name='other role', colour=55, permissions=0, position=3) + } + guild_roles = { + Role(id=41, name='new name', colour=35, permissions=0x8, position=2), + Role(id=53, name='other role', colour=55, permissions=0, position=3) + } + + assert get_roles_for_sync(guild_roles, api_roles) == ( + set(), + {Role(id=41, name='new name', colour=35, permissions=0x8, position=2)}, + set(), + ) + + +def test_get_roles_returns_new_roles_in_first_tuple_element(): + api_roles = { + Role(id=41, name='name', colour=35, permissions=0x8, position=1), + } + guild_roles = { + Role(id=41, name='name', colour=35, permissions=0x8, position=1), + Role(id=53, name='other role', colour=55, permissions=0, position=2) + } + + assert get_roles_for_sync(guild_roles, api_roles) == ( + {Role(id=53, name='other role', colour=55, permissions=0, position=2)}, + set(), + set(), + ) + + +def test_get_roles_returns_roles_to_update_and_new_roles(): + api_roles = { + Role(id=41, name='old name', colour=35, permissions=0x8, position=1), + } + guild_roles = { + Role(id=41, name='new name', colour=40, permissions=0x16, position=2), + Role(id=53, name='other role', colour=55, permissions=0, position=3) + } + + assert get_roles_for_sync(guild_roles, api_roles) == ( + {Role(id=53, name='other role', colour=55, permissions=0, position=3)}, + {Role(id=41, name='new name', colour=40, permissions=0x16, position=2)}, + set(), + ) + + +def test_get_roles_returns_roles_to_delete(): + api_roles = { + Role(id=41, name='name', colour=35, permissions=0x8, position=1), + Role(id=61, name='to delete', colour=99, permissions=0x9, position=2), + } + guild_roles = { + Role(id=41, name='name', colour=35, permissions=0x8, position=1), + } + + assert get_roles_for_sync(guild_roles, api_roles) == ( + set(), + set(), + {Role(id=61, name='to delete', colour=99, permissions=0x9, position=2)}, + ) + + +def test_get_roles_returns_roles_to_delete_update_and_new_roles(): + api_roles = { + Role(id=41, name='not changed', colour=35, permissions=0x8, position=1), + Role(id=61, name='to delete', colour=99, permissions=0x9, position=2), + Role(id=71, name='to update', colour=99, permissions=0x9, position=3), + } + guild_roles = { + Role(id=41, name='not changed', colour=35, permissions=0x8, position=1), + Role(id=81, name='to create', colour=99, permissions=0x9, position=4), + Role(id=71, name='updated', colour=101, permissions=0x5, position=3), + } + + assert get_roles_for_sync(guild_roles, api_roles) == ( + {Role(id=81, name='to create', colour=99, permissions=0x9, position=4)}, + {Role(id=71, name='updated', colour=101, permissions=0x5, position=3)}, + {Role(id=61, name='to delete', colour=99, permissions=0x9, position=2)}, + ) diff --git a/tests/cogs/sync/test_users.py b/tests/cogs/sync/test_users.py new file mode 100644 index 000000000..a863ae35b --- /dev/null +++ b/tests/cogs/sync/test_users.py @@ -0,0 +1,69 @@ +from bot.cogs.sync.syncers import User, get_users_for_sync + + +def fake_user(**kwargs): + kwargs.setdefault('id', 43) + kwargs.setdefault('name', 'bob the test man') + kwargs.setdefault('discriminator', 1337) + kwargs.setdefault('avatar_hash', None) + kwargs.setdefault('roles', (666,)) + kwargs.setdefault('in_guild', True) + return User(**kwargs) + + +def test_get_users_for_sync_returns_nothing_for_empty_params(): + assert get_users_for_sync({}, {}) == (set(), set()) + + +def test_get_users_for_sync_returns_nothing_for_equal_users(): + api_users = {43: fake_user()} + guild_users = {43: fake_user()} + + assert get_users_for_sync(guild_users, api_users) == (set(), set()) + + +def test_get_users_for_sync_returns_users_to_update_on_non_id_field_diff(): + api_users = {43: fake_user()} + guild_users = {43: fake_user(name='new fancy name')} + + assert get_users_for_sync(guild_users, api_users) == ( + set(), + {fake_user(name='new fancy name')} + ) + + +def test_get_users_for_sync_returns_users_to_create_with_new_ids_on_guild(): + api_users = {43: fake_user()} + guild_users = {43: fake_user(), 63: fake_user(id=63)} + + assert get_users_for_sync(guild_users, api_users) == ( + {fake_user(id=63)}, + set() + ) + + +def test_get_users_for_sync_updates_in_guild_field_on_user_leave(): + api_users = {43: fake_user(), 63: fake_user(id=63)} + guild_users = {43: fake_user()} + + assert get_users_for_sync(guild_users, api_users) == ( + set(), + {fake_user(id=63, in_guild=False)} + ) + + +def test_get_users_for_sync_updates_and_creates_users_as_needed(): + api_users = {43: fake_user()} + guild_users = {63: fake_user(id=63)} + + assert get_users_for_sync(guild_users, api_users) == ( + {fake_user(id=63)}, + {fake_user(in_guild=False)} + ) + + +def test_get_users_for_sync_does_not_duplicate_update_users(): + api_users = {43: fake_user(in_guild=False)} + guild_users = {} + + assert get_users_for_sync(guild_users, api_users) == (set(), set()) 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/rules/__init__.py b/tests/rules/__init__.py new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/tests/rules/__init__.py diff --git a/tests/rules/test_attachments.py b/tests/rules/test_attachments.py new file mode 100644 index 000000000..6f025b3cb --- /dev/null +++ b/tests/rules/test_attachments.py @@ -0,0 +1,52 @@ +import asyncio +from dataclasses import dataclass +from typing import Any, List + +import pytest + +from bot.rules import attachments + + +# Using `MagicMock` sadly doesn't work for this usecase +# since it's __eq__ compares the MagicMock's ID. We just +# want to compare the actual attributes we set. +@dataclass +class FakeMessage: + author: str + attachments: List[Any] + + +def msg(total_attachments: int): + return FakeMessage(author='lemon', attachments=list(range(total_attachments))) + + + 'messages', + ( + (msg(0), msg(0), msg(0)), + (msg(2), msg(2)), + (msg(0),), + ) +) +def test_allows_messages_without_too_many_attachments(messages): + last_message, *recent_messages = messages + coro = attachments.apply(last_message, recent_messages, {'max': 5}) + assert asyncio.run(coro) is None + + + ('messages', 'relevant_messages', 'total'), + ( + ((msg(4), msg(0), msg(6)), [msg(4), msg(6)], 10), + ((msg(6),), [msg(6)], 6), + ((msg(1),) * 6, [msg(1)] * 6, 6), + ) +) +def test_disallows_messages_with_too_many_attachments(messages, relevant_messages, total): + last_message, *recent_messages = messages + coro = attachments.apply(last_message, recent_messages, {'max': 5}) + assert asyncio.run(coro) == ( + f"sent {total} attachments in 5s", + ('lemon',), + relevant_messages + ) 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,6 +1,19 @@ [flake8] max-line-length=120 -application_import_names=bot -exclude=.cache,.venv -ignore=B311,W503,E226,S311 +docstring-convention=all import-order-style=pycharm +application_import_names=bot,tests +exclude=.cache,.venv,constants.py +ignore= + B311,W503,E226,S311,T000 + # Missing Docstrings + D100,D104,D105,D107, + # Docstring Whitespace + D203,D212,D214,D215, + # Docstring Quotes + D301,D302, + # Docstring Content + D400,D401,D402,D404,D405,D406,D407,D408,D409,D410,D411,D412,D413,D414,D416,D417 + # Type Annotations + TYP002,TYP003,TYP101,TYP102,TYP204,TYP206 +per-file-ignores=tests/*:D,TYP |