diff options
| -rw-r--r-- | Pipfile | 7 | ||||
| -rw-r--r-- | Pipfile.lock | 479 | ||||
| -rw-r--r-- | azure-pipelines.yml | 2 | ||||
| -rw-r--r-- | bot/__init__.py | 83 | ||||
| -rw-r--r-- | bot/__main__.py | 5 | ||||
| -rw-r--r-- | bot/api.py | 74 | ||||
| -rw-r--r-- | bot/bot.py | 5 | ||||
| -rw-r--r-- | bot/cogs/antispam.py | 33 | ||||
| -rw-r--r-- | bot/cogs/error_handler.py | 25 | ||||
| -rw-r--r-- | bot/cogs/help.py | 7 | ||||
| -rw-r--r-- | bot/cogs/metrics.py | 98 | ||||
| -rw-r--r-- | bot/cogs/moderation/management.py | 7 | ||||
| -rw-r--r-- | bot/cogs/moderation/modlog.py | 22 | ||||
| -rw-r--r-- | bot/cogs/moderation/scheduler.py | 18 | ||||
| -rw-r--r-- | bot/cogs/site.py | 4 | ||||
| -rw-r--r-- | bot/cogs/tags.py | 91 | ||||
| -rw-r--r-- | bot/constants.py | 4 | ||||
| -rw-r--r-- | bot/pagination.py | 20 | ||||
| -rw-r--r-- | bot/utils/messages.py | 55 | ||||
| -rw-r--r-- | bot/utils/time.py | 36 | ||||
| -rw-r--r-- | config-default.yml | 7 | ||||
| -rw-r--r-- | tests/bot/test_api.py | 64 | ||||
| -rw-r--r-- | tox.ini | 6 | 
23 files changed, 494 insertions, 658 deletions
| @@ -4,9 +4,8 @@ verify_ssl = true  name = "pypi"  [packages] -discord-py = "~=1.2" +discord-py = "~=1.3.1"  aiodns = "~=2.0" -logmatic-python = "~=0.1"  aiohttp = "~=3.5"  sphinx = "~=2.2"  markdownify = "~=0.4" @@ -19,12 +18,12 @@ deepdiff = "~=4.0"  requests = "~=2.22"  more_itertools = "~=7.2"  urllib3 = ">=1.24.2,<1.25" -prometheus-async = {extras = ["aiohttp"],version = "~=19.2"} +sentry-sdk = "~=0.14"  [dev-packages]  coverage = "~=4.5"  flake8 = "~=3.7" -flake8-annotations = "~=1.1" +flake8-annotations = "~=2.0"  flake8-bugbear = "~=19.8"  flake8-docstrings = "~=1.4"  flake8-import-order = "~=0.18" diff --git a/Pipfile.lock b/Pipfile.lock index ab5dfb538..fa29bf995 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@  {      "_meta": {          "hash": { -            "sha256": "d9349e8c704b2b2403004039856d8d75aaebc76e4aa93390c4d177f583e73b71" +            "sha256": "c7706a61eb96c06d073898018ea2dbcf5bd3b15d007496e2d60120a65647f31e"          },          "pipfile-spec": 6,          "requires": { @@ -18,11 +18,11 @@      "default": {          "aio-pika": {              "hashes": [ -                "sha256:a5837277e53755078db3a9e8c45bbca605c8ba9ecba7a02d74a7a1779f444723", -                "sha256:fa32e33b4b7d0804dcf439ae6ff24d2f0a83d1ba280ee9f555e647d71d394ff5" +                "sha256:4199122a450dffd8303b7857a9d82657bf1487fe329e489520833b40fbe92406", +                "sha256:fe85c7456e5c060bce4eb9cffab5b2c4d3c563cb72177977b3556c54c8e3aeb6"              ],              "index": "pypi", -            "version": "==6.4.1" +            "version": "==6.5.2"          },          "aiodns": {              "hashes": [ @@ -34,38 +34,28 @@          },          "aiohttp": {              "hashes": [ -                "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" +                "sha256:1e984191d1ec186881ffaed4581092ba04f7c61582a177b187d3a2f07ed9719e", +                "sha256:259ab809ff0727d0e834ac5e8a283dc5e3e0ecc30c4d80b3cd17a4139ce1f326", +                "sha256:2f4d1a4fdce595c947162333353d4a44952a724fba9ca3205a3df99a33d1307a", +                "sha256:32e5f3b7e511aa850829fbe5aa32eb455e5534eaa4b1ce93231d00e2f76e5654", +                "sha256:344c780466b73095a72c616fac5ea9c4665add7fc129f285fbdbca3cccf4612a", +                "sha256:460bd4237d2dbecc3b5ed57e122992f60188afe46e7319116da5eb8a9dfedba4", +                "sha256:4c6efd824d44ae697814a2a85604d8e992b875462c6655da161ff18fd4f29f17", +                "sha256:50aaad128e6ac62e7bf7bd1f0c0a24bc968a0c0590a726d5a955af193544bcec", +                "sha256:6206a135d072f88da3e71cc501c59d5abffa9d0bb43269a6dcd28d66bfafdbdd", +                "sha256:65f31b622af739a802ca6fd1a3076fd0ae523f8485c52924a89561ba10c49b48", +                "sha256:ae55bac364c405caa23a4f2d6cfecc6a0daada500274ffca4a9230e7129eac59", +                "sha256:b778ce0c909a2653741cb4b1ac7015b5c130ab9c897611df43ae6a58523cb965"              ],              "index": "pypi", -            "version": "==3.5.4" +            "version": "==3.6.2"          },          "aiormq": {              "hashes": [ -                "sha256:8c215a970133ab5ee7c478decac55b209af7731050f52d11439fe910fa0f9e9d", -                "sha256:9210f3389200aee7d8067f6435f4a9eff2d3a30b88beb5eaae406ccc11c0fc01" +                "sha256:286e0b0772075580466e45f98f051b9728a9316b9c36f0c14c7bc1409be375b0", +                "sha256:7ed7d6df6b57af7f8bce7d1ebcbdfc32b676192e46703e81e9e217316e56b5bd"              ], -            "version": "==3.2.0" +            "version": "==3.2.1"          },          "alabaster": {              "hashes": [ @@ -112,41 +102,36 @@          },          "cffi": {              "hashes": [ -                "sha256:0b49274afc941c626b605fb59b59c3485c17dc776dc3cc7cc14aca74cc19cc42", -                "sha256:0e3ea92942cb1168e38c05c1d56b0527ce31f1a370f6117f1d490b8dcd6b3a04", -                "sha256:135f69aecbf4517d5b3d6429207b2dff49c876be724ac0c8bf8e1ea99df3d7e5", -                "sha256:19db0cdd6e516f13329cba4903368bff9bb5a9331d3410b1b448daaadc495e54", -                "sha256:2781e9ad0e9d47173c0093321bb5435a9dfae0ed6a762aabafa13108f5f7b2ba", -                "sha256:291f7c42e21d72144bb1c1b2e825ec60f46d0a7468f5346841860454c7aa8f57", -                "sha256:2c5e309ec482556397cb21ede0350c5e82f0eb2621de04b2633588d118da4396", -                "sha256:2e9c80a8c3344a92cb04661115898a9129c074f7ab82011ef4b612f645939f12", -                "sha256:32a262e2b90ffcfdd97c7a5e24a6012a43c61f1f5a57789ad80af1d26c6acd97", -                "sha256:3c9fff570f13480b201e9ab69453108f6d98244a7f495e91b6c654a47486ba43", -                "sha256:415bdc7ca8c1c634a6d7163d43fb0ea885a07e9618a64bda407e04b04333b7db", -                "sha256:42194f54c11abc8583417a7cf4eaff544ce0de8187abaf5d29029c91b1725ad3", -                "sha256:4424e42199e86b21fc4db83bd76909a6fc2a2aefb352cb5414833c030f6ed71b", -                "sha256:4a43c91840bda5f55249413037b7a9b79c90b1184ed504883b72c4df70778579", -                "sha256:599a1e8ff057ac530c9ad1778293c665cb81a791421f46922d80a86473c13346", -                "sha256:5c4fae4e9cdd18c82ba3a134be256e98dc0596af1e7285a3d2602c97dcfa5159", -                "sha256:5ecfa867dea6fabe2a58f03ac9186ea64da1386af2159196da51c4904e11d652", -                "sha256:62f2578358d3a92e4ab2d830cd1c2049c9c0d0e6d3c58322993cc341bdeac22e", -                "sha256:6471a82d5abea994e38d2c2abc77164b4f7fbaaf80261cb98394d5793f11b12a", -                "sha256:6d4f18483d040e18546108eb13b1dfa1000a089bcf8529e30346116ea6240506", -                "sha256:71a608532ab3bd26223c8d841dde43f3516aa5d2bf37b50ac410bb5e99053e8f", -                "sha256:74a1d8c85fb6ff0b30fbfa8ad0ac23cd601a138f7509dc617ebc65ef305bb98d", -                "sha256:7b93a885bb13073afb0aa73ad82059a4c41f4b7d8eb8368980448b52d4c7dc2c", -                "sha256:7d4751da932caaec419d514eaa4215eaf14b612cff66398dd51129ac22680b20", -                "sha256:7f627141a26b551bdebbc4855c1157feeef18241b4b8366ed22a5c7d672ef858", -                "sha256:8169cf44dd8f9071b2b9248c35fc35e8677451c52f795daa2bb4643f32a540bc", -                "sha256:aa00d66c0fab27373ae44ae26a66a9e43ff2a678bf63a9c7c1a9a4d61172827a", -                "sha256:ccb032fda0873254380aa2bfad2582aedc2959186cce61e3a17abc1a55ff89c3", -                "sha256:d754f39e0d1603b5b24a7f8484b22d2904fa551fe865fd0d4c3332f078d20d4e", -                "sha256:d75c461e20e29afc0aee7172a0950157c704ff0dd51613506bd7d82b718e7410", -                "sha256:dcd65317dd15bc0451f3e01c80da2216a31916bdcffd6221ca1202d96584aa25", -                "sha256:e570d3ab32e2c2861c4ebe6ffcad6a8abf9347432a37608fe1fbd157b3f0036b", -                "sha256:fd43a88e045cf992ed09fa724b5315b790525f2676883a6ea64e3263bae6549d" -            ], -            "version": "==1.13.2" +                "sha256:001bf3242a1bb04d985d63e138230802c6c8d4db3668fb545fb5005ddf5bb5ff", +                "sha256:00789914be39dffba161cfc5be31b55775de5ba2235fe49aa28c148236c4e06b", +                "sha256:028a579fc9aed3af38f4892bdcc7390508adabc30c6af4a6e4f611b0c680e6ac", +                "sha256:14491a910663bf9f13ddf2bc8f60562d6bc5315c1f09c704937ef17293fb85b0", +                "sha256:1cae98a7054b5c9391eb3249b86e0e99ab1e02bb0cc0575da191aedadbdf4384", +                "sha256:2089ed025da3919d2e75a4d963d008330c96751127dd6f73c8dc0c65041b4c26", +                "sha256:2d384f4a127a15ba701207f7639d94106693b6cd64173d6c8988e2c25f3ac2b6", +                "sha256:337d448e5a725bba2d8293c48d9353fc68d0e9e4088d62a9571def317797522b", +                "sha256:399aed636c7d3749bbed55bc907c3288cb43c65c4389964ad5ff849b6370603e", +                "sha256:3b911c2dbd4f423b4c4fcca138cadde747abdb20d196c4a48708b8a2d32b16dd", +                "sha256:3d311bcc4a41408cf5854f06ef2c5cab88f9fded37a3b95936c9879c1640d4c2", +                "sha256:62ae9af2d069ea2698bf536dcfe1e4eed9090211dbaafeeedf5cb6c41b352f66", +                "sha256:66e41db66b47d0d8672d8ed2708ba91b2f2524ece3dee48b5dfb36be8c2f21dc", +                "sha256:675686925a9fb403edba0114db74e741d8181683dcf216be697d208857e04ca8", +                "sha256:7e63cbcf2429a8dbfe48dcc2322d5f2220b77b2e17b7ba023d6166d84655da55", +                "sha256:8a6c688fefb4e1cd56feb6c511984a6c4f7ec7d2a1ff31a10254f3c817054ae4", +                "sha256:8c0ffc886aea5df6a1762d0019e9cb05f825d0eec1f520c51be9d198701daee5", +                "sha256:95cd16d3dee553f882540c1ffe331d085c9e629499ceadfbda4d4fde635f4b7d", +                "sha256:99f748a7e71ff382613b4e1acc0ac83bf7ad167fb3802e35e90d9763daba4d78", +                "sha256:b8c78301cefcf5fd914aad35d3c04c2b21ce8629b5e4f4e45ae6812e461910fa", +                "sha256:c420917b188a5582a56d8b93bdd8e0f6eca08c84ff623a4c16e809152cd35793", +                "sha256:c43866529f2f06fe0edc6246eb4faa34f03fe88b64a0a9a942561c8e22f4b71f", +                "sha256:cab50b8c2250b46fe738c77dbd25ce017d5e6fb35d3407606e7a4180656a5a6a", +                "sha256:cef128cb4d5e0b3493f058f10ce32365972c554572ff821e175dbc6f8ff6924f", +                "sha256:cf16e3cf6c0a5fdd9bc10c21687e19d29ad1fe863372b5543deaec1039581a30", +                "sha256:e56c744aa6ff427a607763346e4170629caf7e48ead6921745986db3692f987f", +                "sha256:e577934fc5f8779c554639376beeaa5657d54349096ef24abe8c74c5d9c117c3", +                "sha256:f2b0fa0c01d8a0c7483afd9f31d7ecf2d71760ca24499c8697aeb5ca37dc090c" +            ], +            "version": "==1.14.0"          },          "chardet": {              "hashes": [ @@ -157,41 +142,40 @@          },          "deepdiff": {              "hashes": [ -                "sha256:3457ea7cecd51ba48015d89edbb569358af4d9b9e65e28bdb3209608420627f9", -                "sha256:5e2343398e90538edaa59c0c99207e996a3a834fdc878c666376f632a760c35a" +                "sha256:b3fa588d1eac7fa318ec1fb4f2004568e04cb120a1989feda8e5e7164bcbf07a", +                "sha256:ed7342d3ed3c0c2058a3fb05b477c943c9959ef62223dca9baa3375718a25d87"              ],              "index": "pypi", -            "version": "==4.0.9" +            "version": "==4.2.0"          },          "discord-py": {              "hashes": [ -                "sha256:7c843b523bb011062b453864e75c7b675a03faf573c58d14c9f096e85984329d" +                "sha256:8bfe5628d31771744000f19135c386c74ac337479d7282c26cc1627b9d31f360"              ],              "index": "pypi", -            "version": "==1.2.5" +            "version": "==1.3.1"          },          "docutils": {              "hashes": [ -                "sha256:6c4f696463b79f1fb8ba0c594b63840ebd41f059e92b31957c46b74a4599b6d0", -                "sha256:9e4d7ecfc600058e07ba661411a2b7de2fd0fafa17d1a7f7361cd47b1175c827", -                "sha256:a2aeea129088da402665e92e0b25b04b073c04b2dce4ab65caaa38b7ce2e1a99" +                "sha256:0c5b78adfbf7762415433f5515cd5c9e762339e23369dbe8000d84a4bf4ab3af", +                "sha256:c2de3a60e9e7d07be26b7f2b00ca0309c207e06c100f9cc2a94931fc75a478fc"              ], -            "version": "==0.15.2" +            "version": "==0.16"          },          "fuzzywuzzy": {              "hashes": [ -                "sha256:5ac7c0b3f4658d2743aa17da53a55598144edbc5bee3c6863840636e6926f254", -                "sha256:6f49de47db00e1c71d40ad16da42284ac357936fa9b66bea1df63fed07122d62" +                "sha256:45016e92264780e58972dca1b3d939ac864b78437422beecebb3095f8efd00e8", +                "sha256:928244b28db720d1e0ee7587acf660ea49d7e4c632569cad4f1cd7e68a5f0993"              ],              "index": "pypi", -            "version": "==0.17.0" +            "version": "==0.18.0"          },          "idna": {              "hashes": [ -                "sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407", -                "sha256:ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c" +                "sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb", +                "sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa"              ], -            "version": "==2.8" +            "version": "==2.9"          },          "imagesize": {              "hashes": [ @@ -202,56 +186,43 @@          },          "jinja2": {              "hashes": [ -                "sha256:74320bb91f31270f9551d46522e33af46a80c3d619f4a4bf42b3164d30b5911f", -                "sha256:9fe95f19286cfefaa917656583d020be14e7859c6b0252588391e47db34527de" +                "sha256:93187ffbc7808079673ef52771baa950426fd664d3aad1d0fa3e95644360e250", +                "sha256:b0eaf100007721b5c16c1fc1eecb87409464edc10469ddc9a22a27a99123be49"              ], -            "version": "==2.10.3" -        }, -        "jsonpickle": { -            "hashes": [ -                "sha256:d0c5a4e6cb4e58f6d5406bdded44365c2bcf9c836c4f52910cc9ba7245a59dc2", -                "sha256:d3e922d781b1d0096df2dad89a2e1f47177d7969b596aea806a9d91b4626b29b" -            ], -            "version": "==1.2" -        }, -        "logmatic-python": { -            "hashes": [ -                "sha256:0c15ac9f5faa6a60059b28910db642c3dc7722948c3cc940923f8c9039604342" -            ], -            "index": "pypi", -            "version": "==0.1.7" +            "version": "==2.11.1"          },          "lxml": {              "hashes": [ -                "sha256:00ac0d64949fef6b3693813fe636a2d56d97a5a49b5bbb86e4cc4cc50ebc9ea2", -                "sha256:0571e607558665ed42e450d7bf0e2941d542c18e117b1ebbf0ba72f287ad841c", -                "sha256:0e3f04a7615fdac0be5e18b2406529521d6dbdb0167d2a690ee328bef7807487", -                "sha256:13cf89be53348d1c17b453867da68704802966c433b2bb4fa1f970daadd2ef70", -                "sha256:217262fcf6a4c2e1c7cb1efa08bd9ebc432502abc6c255c4abab611e8be0d14d", -                "sha256:223e544828f1955daaf4cefbb4853bc416b2ec3fd56d4f4204a8b17007c21250", -                "sha256:277cb61fede2f95b9c61912fefb3d43fbd5f18bf18a14fae4911b67984486f5d", -                "sha256:3213f753e8ae86c396e0e066866e64c6b04618e85c723b32ecb0909885211f74", -                "sha256:4690984a4dee1033da0af6df0b7a6bde83f74e1c0c870623797cec77964de34d", -                "sha256:4fcc472ef87f45c429d3b923b925704aa581f875d65bac80f8ab0c3296a63f78", -                "sha256:61409bd745a265a742f2693e4600e4dbd45cc1daebe1d5fad6fcb22912d44145", -                "sha256:678f1963f755c5d9f5f6968dded7b245dd1ece8cf53c1aa9d80e6734a8c7f41d", -                "sha256:6c6d03549d4e2734133badb9ab1c05d9f0ef4bcd31d83e5d2b4747c85cfa21da", -                "sha256:6e74d5f4d6ecd6942375c52ffcd35f4318a61a02328f6f1bd79fcb4ffedf969e", -                "sha256:7b4fc7b1ecc987ca7aaf3f4f0e71bbfbd81aaabf87002558f5bc95da3a865bcd", -                "sha256:7ed386a40e172ddf44c061ad74881d8622f791d9af0b6f5be20023029129bc85", -                "sha256:8f54f0924d12c47a382c600c880770b5ebfc96c9fd94cf6f6bdc21caf6163ea7", -                "sha256:ad9b81351fdc236bda538efa6879315448411a81186c836d4b80d6ca8217cdb9", -                "sha256:bbd00e21ea17f7bcc58dccd13869d68441b32899e89cf6cfa90d624a9198ce85", -                "sha256:c3c289762cc09735e2a8f8a49571d0e8b4f57ea831ea11558247b5bdea0ac4db", -                "sha256:cf4650942de5e5685ad308e22bcafbccfe37c54aa7c0e30cd620c2ee5c93d336", -                "sha256:cfcbc33c9c59c93776aa41ab02e55c288a042211708b72fdb518221cc803abc8", -                "sha256:e301055deadfedbd80cf94f2f65ff23126b232b0d1fea28f332ce58137bcdb18", -                "sha256:ebbfe24df7f7b5c6c7620702496b6419f6a9aa2fd7f005eb731cc80d7b4692b9", -                "sha256:eff69ddbf3ad86375c344339371168640951c302450c5d3e9936e98d6459db06", -                "sha256:f6ed60a62c5f1c44e789d2cf14009423cb1646b44a43e40a9cf6a21f077678a1" -            ], -            "index": "pypi", -            "version": "==4.4.2" +                "sha256:06d4e0bbb1d62e38ae6118406d7cdb4693a3fa34ee3762238bcb96c9e36a93cd", +                "sha256:0701f7965903a1c3f6f09328c1278ac0eee8f56f244e66af79cb224b7ef3801c", +                "sha256:1f2c4ec372bf1c4a2c7e4bb20845e8bcf8050365189d86806bad1e3ae473d081", +                "sha256:4235bc124fdcf611d02047d7034164897ade13046bda967768836629bc62784f", +                "sha256:5828c7f3e615f3975d48f40d4fe66e8a7b25f16b5e5705ffe1d22e43fb1f6261", +                "sha256:585c0869f75577ac7a8ff38d08f7aac9033da2c41c11352ebf86a04652758b7a", +                "sha256:5d467ce9c5d35b3bcc7172c06320dddb275fea6ac2037f72f0a4d7472035cea9", +                "sha256:63dbc21efd7e822c11d5ddbedbbb08cd11a41e0032e382a0fd59b0b08e405a3a", +                "sha256:7bc1b221e7867f2e7ff1933165c0cec7153dce93d0cdba6554b42a8beb687bdb", +                "sha256:8620ce80f50d023d414183bf90cc2576c2837b88e00bea3f33ad2630133bbb60", +                "sha256:8a0ebda56ebca1a83eb2d1ac266649b80af8dd4b4a3502b2c1e09ac2f88fe128", +                "sha256:90ed0e36455a81b25b7034038e40880189169c308a3df360861ad74da7b68c1a", +                "sha256:95e67224815ef86924fbc2b71a9dbd1f7262384bca4bc4793645794ac4200717", +                "sha256:afdb34b715daf814d1abea0317b6d672476b498472f1e5aacbadc34ebbc26e89", +                "sha256:b4b2c63cc7963aedd08a5f5a454c9f67251b1ac9e22fd9d72836206c42dc2a72", +                "sha256:d068f55bda3c2c3fcaec24bd083d9e2eede32c583faf084d6e4b9daaea77dde8", +                "sha256:d5b3c4b7edd2e770375a01139be11307f04341ec709cf724e0f26ebb1eef12c3", +                "sha256:deadf4df349d1dcd7b2853a2c8796593cc346600726eff680ed8ed11812382a7", +                "sha256:df533af6f88080419c5a604d0d63b2c33b1c0c4409aba7d0cb6de305147ea8c8", +                "sha256:e4aa948eb15018a657702fee0b9db47e908491c64d36b4a90f59a64741516e77", +                "sha256:e5d842c73e4ef6ed8c1bd77806bf84a7cb535f9c0cf9b2c74d02ebda310070e1", +                "sha256:ebec08091a22c2be870890913bdadd86fcd8e9f0f22bcb398abd3af914690c15", +                "sha256:edc15fcfd77395e24543be48871c251f38132bb834d9fdfdad756adb6ea37679", +                "sha256:f2b74784ed7e0bc2d02bd53e48ad6ba523c9b36c194260b7a5045071abbb1012", +                "sha256:fa071559f14bd1e92077b1b5f6c22cf09756c6de7139370249eb372854ce51e6", +                "sha256:fd52e796fee7171c4361d441796b64df1acfceb51f29e545e812f16d023c4bbc", +                "sha256:fe976a0f1ef09b3638778024ab9fb8cde3118f203364212c198f71341c0715ca" +            ], +            "index": "pypi", +            "version": "==4.5.0"          },          "markdownify": {              "hashes": [ @@ -266,13 +237,16 @@                  "sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161",                  "sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235",                  "sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5", +                "sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42",                  "sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff",                  "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b",                  "sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1",                  "sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e",                  "sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183",                  "sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66", +                "sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b",                  "sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1", +                "sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15",                  "sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1",                  "sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e",                  "sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b", @@ -289,7 +263,9 @@                  "sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6",                  "sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f",                  "sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f", -                "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7" +                "sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2", +                "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7", +                "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be"              ],              "version": "==1.1.1"          }, @@ -331,10 +307,10 @@          },          "packaging": {              "hashes": [ -                "sha256:aec3fdbb8bc9e4bb65f0634b9f551ced63983a529d6a8931817d52fdd0816ddb", -                "sha256:fe1d8331dfa7cc0a883b49d75fc76380b2ab2734b220fbb87d774e4fd4b851f8" +                "sha256:170748228214b70b672c581a3dd610ee51f733018650740e98c7df862a583f73", +                "sha256:e665345f9eef0c621aa0bf2f8d78cf6d21904eef16a93f020240b704a57f1334"              ], -            "version": "==20.0" +            "version": "==20.1"          },          "pamqp": {              "hashes": [ @@ -343,23 +319,6 @@              ],              "version": "==2.3.0"          }, -        "prometheus-async": { -            "extras": [ -                "aiohttp" -            ], -            "hashes": [ -                "sha256:227f516e5bf98a0dc602348381e182358f8b2ed24a8db05e8e34d9cf027bab83", -                "sha256:3cc68d1f39e9bbf16dbd0b51103d87671b3cbd1d75a72cda472cd9a35cc9d0d2" -            ], -            "index": "pypi", -            "version": "==19.2.0" -        }, -        "prometheus-client": { -            "hashes": [ -                "sha256:71cd24a2b3eb335cb800c7159f423df1bd4dcd5171b234be15e3f31ec9f622da" -            ], -            "version": "==0.7.1" -        },          "pycares": {              "hashes": [                  "sha256:050f00b39ed77ea8a4e555f09417d4b1a6b5baa24bb9531a3e15d003d2319b3f", @@ -422,12 +381,6 @@              "index": "pypi",              "version": "==2.8.1"          }, -        "python-json-logger": { -            "hashes": [ -                "sha256:b7a31162f2a01965a5efb94453ce69230ed208468b0bbc7fdfc56e6d8df2e281" -            ], -            "version": "==0.1.11" -        },          "pytz": {              "hashes": [                  "sha256:1c557d7d0e871de1f5ccd5833f60fb2550652da6be2693c1e02300743d21500d", @@ -454,18 +407,26 @@          },          "requests": {              "hashes": [ -                "sha256:11e007a8a2aa0323f5a921e9e6a2d7e4e67d9877e85773fba9ba6419025cbeb4", -                "sha256:9cf5292fcd0f598c671cfc1e0d7d1a7f13bb8085e9a590f48c010551dc6c4b31" +                "sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee", +                "sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6" +            ], +            "index": "pypi", +            "version": "==2.23.0" +        }, +        "sentry-sdk": { +            "hashes": [ +                "sha256:b06dd27391fd11fb32f84fe054e6a64736c469514a718a99fb5ce1dff95d6b28", +                "sha256:e023da07cfbead3868e1e2ba994160517885a32dfd994fc455b118e37989479b"              ],              "index": "pypi", -            "version": "==2.22.0" +            "version": "==0.14.1"          },          "six": {              "hashes": [ -                "sha256:1f1b7d42e254082a9db6279deae68afb421ceba6158efa6131de7b3003ee93fd", -                "sha256:30f610279e8b2578cab6db20741130331735c781b56053c59c4076da27f06b66" +                "sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a", +                "sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c"              ], -            "version": "==1.13.0" +            "version": "==1.14.0"          },          "snowballstemmer": {              "hashes": [ @@ -483,11 +444,11 @@          },          "sphinx": {              "hashes": [ -                "sha256:298537cb3234578b2d954ff18c5608468229e116a9757af3b831c2b2b4819159", -                "sha256:e6e766b74f85f37a5f3e0773a1e1be8db3fcb799deb58ca6d18b70b0b44542a5" +                "sha256:525527074f2e0c2585f68f73c99b4dc257c34bbe308b27f5f8c7a6e20642742f", +                "sha256:543d39db5f82d83a5c1aa0c10c88f2b6cff2da3e711aa849b2c627b4b403bbd9"              ],              "index": "pypi", -            "version": "==2.3.1" +            "version": "==2.4.2"          },          "sphinxcontrib-applehelp": {              "hashes": [ @@ -541,35 +502,30 @@          },          "websockets": {              "hashes": [ -                "sha256:0e2f7d6567838369af074f0ef4d0b802d19fa1fee135d864acc656ceefa33136", -                "sha256:2a16dac282b2fdae75178d0ed3d5b9bc3258dabfae50196cbb30578d84b6f6a6", -                "sha256:5a1fa6072405648cb5b3688e9ed3b94be683ce4a4e5723e6f5d34859dee495c1", -                "sha256:5c1f55a1274df9d6a37553fef8cff2958515438c58920897675c9bc70f5a0538", -                "sha256:669d1e46f165e0ad152ed8197f7edead22854a6c90419f544e0f234cc9dac6c4", -                "sha256:695e34c4dbea18d09ab2c258994a8bf6a09564e762655408241f6a14592d2908", -                "sha256:6b2e03d69afa8d20253455e67b64de1a82ff8612db105113cccec35d3f8429f0", -                "sha256:79ca7cdda7ad4e3663ea3c43bfa8637fc5d5604c7737f19a8964781abbd1148d", -                "sha256:7fd2dd9a856f72e6ed06f82facfce01d119b88457cd4b47b7ae501e8e11eba9c", -                "sha256:82c0354ac39379d836719a77ee360ef865377aa6fdead87909d50248d0f05f4d", -                "sha256:8f3b956d11c5b301206382726210dc1d3bee1a9ccf7aadf895aaf31f71c3716c", -                "sha256:91ec98640220ae05b34b79ee88abf27f97ef7c61cf525eec57ea8fcea9f7dddb", -                "sha256:952be9540d83dba815569d5cb5f31708801e0bbfc3a8c5aef1890b57ed7e58bf", -                "sha256:99ac266af38ba1b1fe13975aea01ac0e14bb5f3a3200d2c69f05385768b8568e", -                "sha256:9fa122e7adb24232247f8a89f2d9070bf64b7869daf93ac5e19546b409e47e96", -                "sha256:a0873eadc4b8ca93e2e848d490809e0123eea154aa44ecd0109c4d0171869584", -                "sha256:cb998bd4d93af46b8b49ecf5a72c0a98e5cc6d57fdca6527ba78ad89d6606484", -                "sha256:e02e57346f6a68523e3c43bbdf35dde5c440318d1f827208ae455f6a2ace446d", -                "sha256:e79a5a896bcee7fff24a788d72e5c69f13e61369d055f28113e71945a7eb1559", -                "sha256:ee55eb6bcf23ecc975e6b47c127c201b913598f38b6a300075f84eeef2d3baff", -                "sha256:f1414e6cbcea8d22843e7eafdfdfae3dd1aba41d1945f6ca66e4806c07c4f454" -            ], -            "version": "==6.0" -        }, -        "wrapt": { -            "hashes": [ -                "sha256:565a021fd19419476b9362b05eeaa094178de64f8361e44468f9e9d7843901e1" -            ], -            "version": "==1.11.2" +                "sha256:0e4fb4de42701340bd2353bb2eee45314651caa6ccee80dbd5f5d5978888fed5", +                "sha256:1d3f1bf059d04a4e0eb4985a887d49195e15ebabc42364f4eb564b1d065793f5", +                "sha256:20891f0dddade307ffddf593c733a3fdb6b83e6f9eef85908113e628fa5a8308", +                "sha256:295359a2cc78736737dd88c343cd0747546b2174b5e1adc223824bcaf3e164cb", +                "sha256:2db62a9142e88535038a6bcfea70ef9447696ea77891aebb730a333a51ed559a", +                "sha256:3762791ab8b38948f0c4d281c8b2ddfa99b7e510e46bd8dfa942a5fff621068c", +                "sha256:3db87421956f1b0779a7564915875ba774295cc86e81bc671631379371af1170", +                "sha256:3ef56fcc7b1ff90de46ccd5a687bbd13a3180132268c4254fc0fa44ecf4fc422", +                "sha256:4f9f7d28ce1d8f1295717c2c25b732c2bc0645db3215cf757551c392177d7cb8", +                "sha256:5c01fd846263a75bc8a2b9542606927cfad57e7282965d96b93c387622487485", +                "sha256:5c65d2da8c6bce0fca2528f69f44b2f977e06954c8512a952222cea50dad430f", +                "sha256:751a556205d8245ff94aeef23546a1113b1dd4f6e4d102ded66c39b99c2ce6c8", +                "sha256:7ff46d441db78241f4c6c27b3868c9ae71473fe03341340d2dfdbe8d79310acc", +                "sha256:965889d9f0e2a75edd81a07592d0ced54daa5b0785f57dc429c378edbcffe779", +                "sha256:9b248ba3dd8a03b1a10b19efe7d4f7fa41d158fdaa95e2cf65af5a7b95a4f989", +                "sha256:9bef37ee224e104a413f0780e29adb3e514a5b698aabe0d969a6ba426b8435d1", +                "sha256:c1ec8db4fac31850286b7cd3b9c0e1b944204668b8eb721674916d4e28744092", +                "sha256:c8a116feafdb1f84607cb3b14aa1418424ae71fee131642fc568d21423b51824", +                "sha256:ce85b06a10fc65e6143518b96d3dca27b081a740bae261c2fb20375801a9d56d", +                "sha256:d705f8aeecdf3262379644e4b55107a3b55860eb812b673b28d0fbc347a60c55", +                "sha256:e898a0863421650f0bebac8ba40840fc02258ef4714cb7e1fd76b6a6354bda36", +                "sha256:f8a7bff6e8664afc4e6c28b983845c5bc14965030e3fb98789734d416af77c4b" +            ], +            "version": "==8.1"          },          "yarl": {              "hashes": [ @@ -595,6 +551,13 @@          }      },      "develop": { +        "appdirs": { +            "hashes": [ +                "sha256:9e5896d1372858f8dd3344faf4e5014d21849c756c8d5701f78f8a103b372d92", +                "sha256:d8b24664561d0d34ddfaec54636d502d7cea6e29c3eaf68f3df6180863e2166e" +            ], +            "version": "==1.4.3" +        },          "aspy.yaml": {              "hashes": [                  "sha256:463372c043f70160a9ec950c3f1e4c3a82db5fca01d334b6bc89c7164d744bdc", @@ -618,10 +581,10 @@          },          "cfgv": {              "hashes": [ -                "sha256:edb387943b665bf9c434f717bf630fa78aecd53d5900d2e05da6ad6048553144", -                "sha256:fbd93c9ab0a523bf7daec408f3be2ed99a980e20b2d19b50fc184ca6b820d289" +                "sha256:04b093b14ddf9fd4d17c53ebfd55582d27b76ed30050193c14e560770c5360eb", +                "sha256:f22b426ed59cd2ab2b54ff96608d846c33dfb8766a67f0b4a6ce130ce244414f"              ], -            "version": "==2.0.1" +            "version": "==3.0.0"          },          "chardet": {              "hashes": [ @@ -675,6 +638,12 @@              "index": "pypi",              "version": "==4.5.4"          }, +        "distlib": { +            "hashes": [ +                "sha256:2e166e231a26b36d6dfe35a48c4464346620f8645ed0ace01ee31822b288de21" +            ], +            "version": "==0.3.0" +        },          "dodgy": {              "hashes": [                  "sha256:28323cbfc9352139fdd3d316fa17f325cc0e9ac74438cbba51d70f9b48f86c3a", @@ -697,6 +666,13 @@              ],              "version": "==0.3"          }, +        "filelock": { +            "hashes": [ +                "sha256:18d82244ee114f543149c66a6e0c14e9c4f8a1044b5cdaadd0f82159d6a6ff59", +                "sha256:929b7d63ec5b7d6b71b0fa5ac14e030b3f70b75747cef1b10da9b879fef15836" +            ], +            "version": "==3.0.12" +        },          "flake8": {              "hashes": [                  "sha256:45681a117ecc81e870cbf1262835ae4af5e7a8b08e40b944a8a6e6b895914cfb", @@ -707,11 +683,11 @@          },          "flake8-annotations": {              "hashes": [ -                "sha256:05b85538014c850a86dce7374bb6621c64481c24e35e8e90af1315f4d7a3dbaa", -                "sha256:43e5233a76fda002b91a54a7cc4510f099c4bfd6279502ec70164016250eebd1" +                "sha256:19a6637a5da1bb7ea7948483ca9e2b9e15b213e687e7bf5ff8c1bfc91c185006", +                "sha256:bb033b72cdd3a2b0a530bbdf2081f12fbea7d70baeaaebb5899723a45f424b8e"              ],              "index": "pypi", -            "version": "==1.1.3" +            "version": "==2.0.0"          },          "flake8-bugbear": {              "hashes": [ @@ -739,11 +715,11 @@          },          "flake8-string-format": {              "hashes": [ -                "sha256:68ea72a1a5b75e7018cae44d14f32473c798cf73d75cbaed86c6a9a907b770b2", -                "sha256:774d56103d9242ed968897455ef49b7d6de272000cfa83de5814273a868832f1" +                "sha256:65f3da786a1461ef77fca3780b314edb2853c377f2e35069723348c8917deaa2", +                "sha256:812ff431f10576a74c89be4e85b8e075a705be39bc40c4b4278b5b13e2afa9af"              ],              "index": "pypi", -            "version": "==0.2.3" +            "version": "==0.3.0"          },          "flake8-tidy-imports": {              "hashes": [ @@ -762,25 +738,25 @@          },          "identify": {              "hashes": [ -                "sha256:6f44e637caa40d1b4cb37f6ed3b262ede74901d28b1cc5b1fc07360871edd65d", -                "sha256:72e9c4ed3bc713c7045b762b0d2e2115c572b85abfc1f4604f5a4fd4c6642b71" +                "sha256:1222b648251bdcb8deb240b294f450fbf704c7984e08baa92507e4ea10b436d5", +                "sha256:d824ebe21f38325c771c41b08a95a761db1982f1fc0eee37c6c97df3f1636b96"              ], -            "version": "==1.4.9" +            "version": "==1.4.11"          },          "idna": {              "hashes": [ -                "sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407", -                "sha256:ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c" +                "sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb", +                "sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa"              ], -            "version": "==2.8" +            "version": "==2.9"          },          "importlib-metadata": {              "hashes": [ -                "sha256:bdd9b7c397c273bcc9a11d6629a38487cd07154fa255a467bf704cd2c258e359", -                "sha256:f17c015735e1a88296994c0697ecea7e11db24290941983b08c9feb30921e6d8" +                "sha256:06f5b3a99029c7134207dd882428a66992a9de2bef7c2b699b5641f9886c3302", +                "sha256:b97607a1a18a5100839aec1dc26a1ea17ee0d93b20b0f008d80a5a050afb200b"              ],              "markers": "python_version < '3.8'", -            "version": "==1.4.0" +            "version": "==1.5.0"          },          "mccabe": {              "hashes": [ @@ -789,26 +765,18 @@              ],              "version": "==0.6.1"          }, -        "more-itertools": { -            "hashes": [ -                "sha256:409cd48d4db7052af495b09dec721011634af3753ae1ef92d2b32f73a745f832", -                "sha256:92b8c4b06dac4f0611c0729b2f2ede52b2e1bac1ab48f089c7ddc12e26bb60c4" -            ], -            "index": "pypi", -            "version": "==7.2.0" -        },          "nodeenv": {              "hashes": [ -                "sha256:561057acd4ae3809e665a9aaaf214afff110bbb6a6d5c8a96121aea6878408b3" +                "sha256:5b2438f2e42af54ca968dd1b374d14a1194848955187b0e5e4be1f73813a5212"              ], -            "version": "==1.3.4" +            "version": "==1.3.5"          },          "packaging": {              "hashes": [ -                "sha256:aec3fdbb8bc9e4bb65f0634b9f551ced63983a529d6a8931817d52fdd0816ddb", -                "sha256:fe1d8331dfa7cc0a883b49d75fc76380b2ab2734b220fbb87d774e4fd4b851f8" +                "sha256:170748228214b70b672c581a3dd610ee51f733018650740e98c7df862a583f73", +                "sha256:e665345f9eef0c621aa0bf2f8d78cf6d21904eef16a93f020240b704a57f1334"              ], -            "version": "==20.0" +            "version": "==20.1"          },          "pre-commit": {              "hashes": [ @@ -865,11 +833,11 @@          },          "requests": {              "hashes": [ -                "sha256:11e007a8a2aa0323f5a921e9e6a2d7e4e67d9877e85773fba9ba6419025cbeb4", -                "sha256:9cf5292fcd0f598c671cfc1e0d7d1a7f13bb8085e9a590f48c010551dc6c4b31" +                "sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee", +                "sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6"              ],              "index": "pypi", -            "version": "==2.22.0" +            "version": "==2.23.0"          },          "safety": {              "hashes": [ @@ -881,10 +849,10 @@          },          "six": {              "hashes": [ -                "sha256:1f1b7d42e254082a9db6279deae68afb421ceba6158efa6131de7b3003ee93fd", -                "sha256:30f610279e8b2578cab6db20741130331735c781b56053c59c4076da27f06b66" +                "sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a", +                "sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c"              ], -            "version": "==1.13.0" +            "version": "==1.14.0"          },          "snowballstemmer": {              "hashes": [ @@ -902,29 +870,30 @@          },          "typed-ast": {              "hashes": [ -                "sha256:1170afa46a3799e18b4c977777ce137bb53c7485379d9706af8a59f2ea1aa161", -                "sha256:18511a0b3e7922276346bcb47e2ef9f38fb90fd31cb9223eed42c85d1312344e", -                "sha256:262c247a82d005e43b5b7f69aff746370538e176131c32dda9cb0f324d27141e", -                "sha256:2b907eb046d049bcd9892e3076c7a6456c93a25bebfe554e931620c90e6a25b0", -                "sha256:354c16e5babd09f5cb0ee000d54cfa38401d8b8891eefa878ac772f827181a3c", -                "sha256:48e5b1e71f25cfdef98b013263a88d7145879fbb2d5185f2a0c79fa7ebbeae47", -                "sha256:4e0b70c6fc4d010f8107726af5fd37921b666f5b31d9331f0bd24ad9a088e631", -                "sha256:630968c5cdee51a11c05a30453f8cd65e0cc1d2ad0d9192819df9978984529f4", -                "sha256:66480f95b8167c9c5c5c87f32cf437d585937970f3fc24386f313a4c97b44e34", -                "sha256:71211d26ffd12d63a83e079ff258ac9d56a1376a25bc80b1cdcdf601b855b90b", -                "sha256:7954560051331d003b4e2b3eb822d9dd2e376fa4f6d98fee32f452f52dd6ebb2", -                "sha256:838997f4310012cf2e1ad3803bce2f3402e9ffb71ded61b5ee22617b3a7f6b6e", -                "sha256:95bd11af7eafc16e829af2d3df510cecfd4387f6453355188342c3e79a2ec87a", -                "sha256:bc6c7d3fa1325a0c6613512a093bc2a2a15aeec350451cbdf9e1d4bffe3e3233", -                "sha256:cc34a6f5b426748a507dd5d1de4c1978f2eb5626d51326e43280941206c209e1", -                "sha256:d755f03c1e4a51e9b24d899561fec4ccaf51f210d52abdf8c07ee2849b212a36", -                "sha256:d7c45933b1bdfaf9f36c579671fec15d25b06c8398f113dab64c18ed1adda01d", -                "sha256:d896919306dd0aa22d0132f62a1b78d11aaf4c9fc5b3410d3c666b818191630a", -                "sha256:fdc1c9bbf79510b76408840e009ed65958feba92a88833cdceecff93ae8fff66", -                "sha256:ffde2fbfad571af120fcbfbbc61c72469e72f550d676c3342492a9dfdefb8f12" +                "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355", +                "sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919", +                "sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa", +                "sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652", +                "sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75", +                "sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01", +                "sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d", +                "sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1", +                "sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907", +                "sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c", +                "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3", +                "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b", +                "sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614", +                "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb", +                "sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b", +                "sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41", +                "sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6", +                "sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34", +                "sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe", +                "sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4", +                "sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7"              ],              "markers": "python_version < '3.8'", -            "version": "==1.4.0" +            "version": "==1.4.1"          },          "unittest-xml-reporting": {              "hashes": [ @@ -944,17 +913,17 @@          },          "virtualenv": {              "hashes": [ -                "sha256:0d62c70883c0342d59c11d0ddac0d954d0431321a41ab20851facf2b222598f3", -                "sha256:55059a7a676e4e19498f1aad09b8313a38fcc0cdbe4fdddc0e9b06946d21b4bb" +                "sha256:08f3623597ce73b85d6854fb26608a6f39ee9d055c81178dc6583803797f8994", +                "sha256:de2cbdd5926c48d7b84e0300dea9e8f276f61d186e8e49223d71d91250fbaebd"              ], -            "version": "==16.7.9" +            "version": "==20.0.4"          },          "zipp": {              "hashes": [ -                "sha256:3718b1cbcd963c7d4c5511a8240812904164b7f381b647143a89d3b98f9bcd8e", -                "sha256:f06903e9f1f43b12d371004b4ac7b06ab39a44adc747266928ae6debfa7b3335" +                "sha256:12248a63bbdf7548f89cb4c7cda4681e537031eda29c02ea29674bc6854460c2", +                "sha256:7c0f8e91abc0dc07a5068f315c52cb30c66bfbc581e5b50704c8a2f6ebae794a"              ], -            "version": "==0.6.0" +            "version": "==3.0.0"          }      }  } diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 0400ac4d2..874364a6f 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -30,7 +30,7 @@ jobs:        - script: python -m flake8          displayName: 'Run linter' -      - script: BOT_API_KEY=foo BOT_TOKEN=bar WOLFRAM_API_KEY=baz REDDIT_CLIENT_ID=spam REDDIT_SECRET=ham coverage run -m xmlrunner +      - script: BOT_API_KEY=foo BOT_SENTRY_DSN=blah BOT_TOKEN=bar WOLFRAM_API_KEY=baz REDDIT_CLIENT_ID=spam REDDIT_SECRET=ham coverage run -m xmlrunner          displayName: Run tests        - script: coverage report -m && coverage xml -o coverage.xml diff --git a/bot/__init__.py b/bot/__init__.py index 789ace5c0..923ef517b 100644 --- a/bot/__init__.py +++ b/bot/__init__.py @@ -4,11 +4,8 @@ import sys  from logging import Logger, StreamHandler, handlers  from pathlib import Path -from logmatic import JsonFormatter - - -logging.TRACE = 5 -logging.addLevelName(logging.TRACE, "TRACE") +TRACE_LEVEL = logging.TRACE = 5 +logging.addLevelName(TRACE_LEVEL, "TRACE")  def monkeypatch_trace(self: logging.Logger, msg: str, *args, **kwargs) -> None: @@ -20,75 +17,29 @@ def monkeypatch_trace(self: logging.Logger, msg: str, *args, **kwargs) -> None:      logger.trace("Houston, we have an %s", "interesting problem", exc_info=1)      """ -    if self.isEnabledFor(logging.TRACE): -        self._log(logging.TRACE, msg, args, **kwargs) +    if self.isEnabledFor(TRACE_LEVEL): +        self._log(TRACE_LEVEL, msg, args, **kwargs)  Logger.trace = monkeypatch_trace -# Set up logging -logging_handlers = [] - -# We can't import this yet, so we have to define it ourselves -DEBUG_MODE = True if 'local' in os.environ.get("SITE_URL", "local") else False - -LOG_DIR = Path("logs") -LOG_DIR.mkdir(exist_ok=True) - -if DEBUG_MODE: -    logging_handlers.append(StreamHandler(stream=sys.stdout)) - -    json_handler = logging.FileHandler(filename=Path(LOG_DIR, "log.json"), mode="w") -    json_handler.formatter = JsonFormatter() -    logging_handlers.append(json_handler) -else: - -    logfile = Path(LOG_DIR, "bot.log") -    megabyte = 1048576 - -    filehandler = handlers.RotatingFileHandler(logfile, maxBytes=(megabyte*5), backupCount=7) -    logging_handlers.append(filehandler) - -    json_handler = logging.StreamHandler(stream=sys.stdout) -    json_handler.formatter = JsonFormatter() -    logging_handlers.append(json_handler) - - -logging.basicConfig( -    format="%(asctime)s 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 -) - -log = logging.getLogger(__name__) - - -for key, value in logging.Logger.manager.loggerDict.items(): -    # Force all existing loggers to the correct level and handlers -    # This happens long before we instantiate our loggers, so -    # those should still have the expected level - -    if key == "bot": -        continue - -    if not isinstance(value, logging.Logger): -        # There might be some logging.PlaceHolder objects in there -        continue +DEBUG_MODE = 'local' in os.environ.get("SITE_URL", "local") -    if DEBUG_MODE: -        value.setLevel(logging.DEBUG) -    else: -        value.setLevel(logging.INFO) +log_format = logging.Formatter("%(asctime)s | %(name)s | %(levelname)s | %(message)s") -    for handler in value.handlers.copy(): -        value.removeHandler(handler) +stream_handler = StreamHandler(stream=sys.stdout) +stream_handler.setFormatter(log_format) -    for handler in logging_handlers: -        value.addHandler(handler) +log_file = Path("logs", "bot.log") +log_file.parent.mkdir(exist_ok=True) +file_handler = handlers.RotatingFileHandler(log_file, maxBytes=5242880, backupCount=7) +file_handler.setFormatter(log_format) +root_log = logging.getLogger() +root_log.setLevel(TRACE_LEVEL if DEBUG_MODE else logging.INFO) +root_log.addHandler(stream_handler) +root_log.addHandler(file_handler) -# Silence irrelevant loggers -logging.getLogger("aio_pika").setLevel(logging.ERROR)  logging.getLogger("discord").setLevel(logging.ERROR)  logging.getLogger("websockets").setLevel(logging.ERROR) +logging.getLogger(__name__).setLevel(TRACE_LEVEL) diff --git a/bot/__main__.py b/bot/__main__.py index 61271a692..2ed078903 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -1,10 +1,14 @@  import discord +import sentry_sdk  from discord.ext.commands import when_mentioned_or  from bot import patches  from bot.bot import Bot  from bot.constants import Bot as BotConfig, DEBUG_MODE +sentry_sdk.init( +    dsn=BotConfig.sentry_dsn +)  bot = Bot(      command_prefix=when_mentioned_or(BotConfig.prefix), @@ -40,7 +44,6 @@ bot.load_extension("bot.cogs.duck_pond")  bot.load_extension("bot.cogs.free")  bot.load_extension("bot.cogs.information")  bot.load_extension("bot.cogs.jams") -bot.load_extension("bot.cogs.metrics")  bot.load_extension("bot.cogs.moderation")  bot.load_extension("bot.cogs.off_topic_names")  bot.load_extension("bot.cogs.reddit") diff --git a/bot/api.py b/bot/api.py index 56db99828..fb126b384 100644 --- a/bot/api.py +++ b/bot/api.py @@ -141,77 +141,3 @@ def loop_is_running() -> bool:      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/bot.py b/bot/bot.py index 930aaf70e..cecee7b68 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -4,7 +4,6 @@ from typing import Optional  import aiohttp  from discord.ext import commands -from prometheus_async.aio.web import start_http_server as start_prometheus_http_server  from bot import api @@ -28,8 +27,6 @@ class Bot(commands.Bot):          self.http_session: Optional[aiohttp.ClientSession] = None          self.api_client = api.APIClient(loop=self.loop, connector=self.connector) -        log.addHandler(api.APILoggingHandler(self.api_client)) -      def add_cog(self, cog: commands.Cog) -> None:          """Adds a "cog" to the bot and logs the operation."""          super().add_cog(cog) @@ -51,6 +48,4 @@ class Bot(commands.Bot):          """Open an aiohttp session before logging in and connecting to Discord."""          self.http_session = aiohttp.ClientSession(connector=self.connector) -        await start_prometheus_http_server(addr="0.0.0.0", port=9330) -        log.debug("Started Prometheus server on port 9330.")          await super().start(*args, **kwargs) diff --git a/bot/cogs/antispam.py b/bot/cogs/antispam.py index f454061a6..f67ef6f05 100644 --- a/bot/cogs/antispam.py +++ b/bot/cogs/antispam.py @@ -19,6 +19,7 @@ from bot.constants import (      STAFF_ROLES,  )  from bot.converters import Duration +from bot.utils.messages import send_attachments  log = logging.getLogger(__name__) @@ -45,8 +46,9 @@ class DeletionContext:      members: Dict[int, Member] = field(default_factory=dict)      rules: Set[str] = field(default_factory=set)      messages: Dict[int, Message] = field(default_factory=dict) +    attachments: List[List[str]] = field(default_factory=list) -    def add(self, rule_name: str, members: Iterable[Member], messages: Iterable[Message]) -> None: +    async 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) @@ -58,6 +60,11 @@ class DeletionContext:              if message.id not in self.messages:                  self.messages[message.id] = message +                # Re-upload attachments +                destination = message.guild.get_channel(Channels.attachment_log) +                urls = await send_attachments(message, destination, link_large=False) +                self.attachments.append(urls) +      async def upload_messages(self, actor_id: int, modlog: ModLog) -> None:          """Method that takes care of uploading the queue and posting modlog alert."""          triggered_by_users = ", ".join(f"{m} (`{m.id}`)" for m in self.members.values()) @@ -70,7 +77,7 @@ class DeletionContext:          # 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) +            url = await modlog.upload_log(self.messages.values(), actor_id, self.attachments)              mod_alert_message += f"A complete log of the offending messages can be found [here]({url})"          else:              mod_alert_message += "Message:\n" @@ -98,7 +105,7 @@ class DeletionContext:  class AntiSpam(Cog):      """Cog that controls our anti-spam measures.""" -    def __init__(self, bot: Bot, validation_errors: bool) -> None: +    def __init__(self, bot: Bot, validation_errors: Dict[str, str]) -> None:          self.bot = bot          self.validation_errors = validation_errors          role_id = AntiSpamConfig.punishment['role_id'] @@ -106,7 +113,6 @@ class AntiSpam(Cog):          self.expiration_date_converter = Duration()          self.message_deletion_queue = dict() -        self.queue_consumption_tasks = dict()          self.bot.loop.create_task(self.alert_on_validation_error()) @@ -180,15 +186,14 @@ class AntiSpam(Cog):                  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) -                    ) +                channel = message.channel +                if channel.id not in self.message_deletion_queue: +                    log.trace(f"Creating queue for channel `{channel.id}`") +                    self.message_deletion_queue[message.channel.id] = DeletionContext(channel) +                    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( +                await self.message_deletion_queue[message.channel.id].add(                      rule_name=rule_name,                      members=members,                      messages=relevant_messages @@ -202,7 +207,7 @@ class AntiSpam(Cog):                          self.punish(message, member, full_reason)                      ) -                await self.maybe_delete_messages(message.channel, relevant_messages) +                await self.maybe_delete_messages(channel, relevant_messages)                  break      async def punish(self, msg: Message, member: Member, reason: str) -> None: @@ -255,10 +260,10 @@ class AntiSpam(Cog):          await deletion_context.upload_messages(self.bot.user.id, self.mod_log) -def validate_config(rules: Mapping = AntiSpamConfig.rules) -> Dict[str, str]: +def validate_config(rules_: Mapping = AntiSpamConfig.rules) -> Dict[str, str]:      """Validates the antispam configs."""      validation_errors = {} -    for name, config in rules.items(): +    for name, config in rules_.items():          if name not in RULE_FUNCTION_MAPPING:              log.error(                  f"Unrecognized antispam rule `{name}`. " diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index 52893b2ee..0abb7e521 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -15,6 +15,7 @@ from discord.ext.commands import (      UserInputError,  )  from discord.ext.commands import Cog, Context +from sentry_sdk import push_scope  from bot.api import ResponseCodeError  from bot.bot import Bot @@ -147,10 +148,26 @@ class ErrorHandler(Cog):              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 + +        with push_scope() as scope: +            scope.user = { +                "id": ctx.author.id, +                "username": str(ctx.author) +            } + +            scope.set_tag("command", ctx.command.qualified_name) +            scope.set_tag("message_id", ctx.message.id) +            scope.set_tag("channel_id", ctx.channel.id) + +            scope.set_extra("full_message", ctx.message.content) + +            if ctx.guild is not None: +                scope.set_extra( +                    "jump_to", +                    f"https://discordapp.com/channels/{ctx.guild.id}/{ctx.channel.id}/{ctx.message.id}" +                ) + +            log.error(f"Error executing command invoked by {ctx.message.author}: {ctx.message.content}", exc_info=e)  def setup(bot: Bot) -> None: diff --git a/bot/cogs/help.py b/bot/cogs/help.py index 6385fa467..fd5bbc3ca 100644 --- a/bot/cogs/help.py +++ b/bot/cogs/help.py @@ -11,20 +11,21 @@ from fuzzywuzzy import fuzz, process  from bot import constants  from bot.bot import Bot -from bot.constants import Channels, STAFF_ROLES +from bot.constants import Channels, Emojis, STAFF_ROLES  from bot.decorators import redirect_output  from bot.pagination import ( -    DELETE_EMOJI, FIRST_EMOJI, LAST_EMOJI, +    FIRST_EMOJI, LAST_EMOJI,      LEFT_EMOJI, LinePaginator, RIGHT_EMOJI,  ) +DELETE_EMOJI = Emojis.trashcan  REACTIONS = {      FIRST_EMOJI: 'first',      LEFT_EMOJI: 'back',      RIGHT_EMOJI: 'next',      LAST_EMOJI: 'end', -    DELETE_EMOJI: 'stop' +    DELETE_EMOJI: 'stop',  }  Cog = namedtuple('Cog', ['name', 'description', 'commands']) diff --git a/bot/cogs/metrics.py b/bot/cogs/metrics.py deleted file mode 100644 index 47c3cc55e..000000000 --- a/bot/cogs/metrics.py +++ /dev/null @@ -1,98 +0,0 @@ -from collections import defaultdict - -from discord import Member, Message -from discord.ext.commands import Cog, Context -from prometheus_client import Counter, Gauge - -from bot.bot import Bot - - -class Metrics(Cog): -    """ -    Exports metrics for Prometheus. - -    See https://github.com/prometheus/client_python for metric documentation. -    """ - -    PREFIX = 'pydis_bot' - -    def __init__(self, bot: Bot) -> None: -        self.bot = bot - -        self.guild_members = Gauge( -            name=f'{self.PREFIX}_guild_members', -            documentation="Total members by guild by status.", -            labelnames=('guild_id', 'status') -        ) -        self.guild_messages = Counter( -            name=f'{self.PREFIX}_guild_messages', -            documentation="Guild messages by guild by channel.", -            labelnames=('channel_id', 'guild_id', 'channel_name') -        ) -        self.command_completions = Counter( -            name=f'{self.PREFIX}_command_completions', -            documentation="Completed commands by command, user, and guild.", -            labelnames=('guild_id', 'user_id', 'user_name', 'command') -        ) - -    @Cog.listener() -    async def on_ready(self) -> None: -        """Initialize the guild member counter.""" -        members_by_status = defaultdict(lambda: defaultdict(int)) - -        for guild in self.bot.guilds: -            if guild.large: -                await self.bot.request_offline_members(guild) -            for member in guild.members: -                members_by_status[guild.id][member.status] += 1 - -        for guild_id, members in members_by_status.items(): -            for status, count in members.items(): -                self.guild_members.labels(guild_id=guild_id, status=str(status)).set(count) - -    @Cog.listener() -    async def on_member_join(self, member: Member) -> None: -        """Increment the member gauge.""" -        self.guild_members.labels(guild_id=member.guild.id, status=str(member.status)).inc() - -    @Cog.listener() -    async def on_member_leave(self, member: Member) -> None: -        """Decrement the member gauge.""" -        self.guild_members.labels(guild_id=member.guild.id, status=str(member.status)).dec() - -    @Cog.listener() -    async def on_member_update(self, before: Member, after: Member) -> None: -        """Update member gauges for the new and old status if applicable.""" -        if before.status is not after.status: -            self.guild_members.labels(guild_id=after.guild.id, status=str(before.status)).dec() -            self.guild_members.labels(guild_id=after.guild.id, status=str(after.status)).inc() - -    @Cog.listener() -    async def on_message(self, message: Message) -> None: -        """Increment the guild message counter.""" -        self.guild_messages.labels( -            channel_id=message.channel.id, -            channel_name=message.channel.name, -            guild_id=message.guild.id, -        ).inc() - -    @Cog.listener() -    async def on_command_completion(self, ctx: Context) -> None: -        """Increment the command completion counter.""" -        if ctx.message.guild is not None: -            if ctx.command.full_parent_name: -                command = f'{ctx.command.full_parent_name} {ctx.command.name}' -            else: -                command = ctx.command.name - -            self.command_completions.labels( -                guild_id=ctx.message.guild.id, -                user_id=ctx.author.id, -                user_name=str(ctx.author), -                command=command, -            ).inc() - - -def setup(bot: Bot) -> None: -    """Load the Metrics cog.""" -    bot.add_cog(Metrics(bot)) diff --git a/bot/cogs/moderation/management.py b/bot/cogs/moderation/management.py index 0636422d3..f2964cd78 100644 --- a/bot/cogs/moderation/management.py +++ b/bot/cogs/moderation/management.py @@ -130,8 +130,11 @@ class ModManagement(commands.Cog):          # Re-schedule infraction if the expiration has been updated          if 'expires_at' in request_data:              self.infractions_cog.cancel_task(new_infraction['id']) -            loop = asyncio.get_event_loop() -            self.infractions_cog.schedule_task(loop, new_infraction['id'], new_infraction) + +            # If the infraction was not marked as permanent, schedule a new expiration task +            if request_data['expires_at']: +                loop = asyncio.get_event_loop() +                self.infractions_cog.schedule_task(loop, new_infraction['id'], new_infraction)              log_text += f"""                  Previous expiry: {old_infraction['expires_at'] or "Permanent"} diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py index c78eb24a7..e8ae0dbe6 100644 --- a/bot/cogs/moderation/modlog.py +++ b/bot/cogs/moderation/modlog.py @@ -4,6 +4,7 @@ import itertools  import logging  import typing as t  from datetime import datetime +from itertools import zip_longest  import discord  from dateutil.relativedelta import relativedelta @@ -42,14 +43,16 @@ class ModLog(Cog, name="ModLog"):          self._cached_deletes = []          self._cached_edits = [] -    async def upload_log(self, messages: t.List[discord.Message], actor_id: int) -> str: -        """ -        Uploads the log data to the database via an API endpoint for uploading logs. - -        Used in several mod log embeds. +    async def upload_log( +        self, +        messages: t.Iterable[discord.Message], +        actor_id: int, +        attachments: t.Iterable[t.List[str]] = None +    ) -> str: +        """Upload message logs to the database and return a URL to a page for viewing the logs.""" +        if attachments is None: +            attachments = [] -        Returns a URL that can be used to view the log. -        """          response = await self.bot.api_client.post(              'bot/deleted-messages',              json={ @@ -61,9 +64,10 @@ class ModLog(Cog, name="ModLog"):                          'author': message.author.id,                          'channel_id': message.channel.id,                          'content': message.content, -                        'embeds': [embed.to_dict() for embed in message.embeds] +                        'embeds': [embed.to_dict() for embed in message.embeds], +                        'attachments': attachment,                      } -                    for message in messages +                    for message, attachment in zip_longest(messages, attachments)                  ]              }          ) diff --git a/bot/cogs/moderation/scheduler.py b/bot/cogs/moderation/scheduler.py index e14c302cb..c0de0e4da 100644 --- a/bot/cogs/moderation/scheduler.py +++ b/bot/cogs/moderation/scheduler.py @@ -309,16 +309,23 @@ class InfractionScheduler(Scheduler):          guild = self.bot.get_guild(constants.Guild.id)          mod_role = guild.get_role(constants.Roles.moderator)          user_id = infraction["user"] +        actor = infraction["actor"]          type_ = infraction["type"]          id_ = infraction["id"] +        inserted_at = infraction["inserted_at"] +        expiry = infraction["expires_at"]          log.info(f"Marking infraction #{id_} as inactive (expired).") +        expiry = dateutil.parser.isoparse(expiry).replace(tzinfo=None) if expiry else None +        created = time.format_infraction_with_duration(inserted_at, expiry) +          log_content = None          log_text = { -            "Member": str(user_id), -            "Actor": str(self.bot.user), -            "Reason": infraction["reason"] +            "Member": f"<@{user_id}>", +            "Actor": str(self.bot.get_user(actor) or actor), +            "Reason": infraction["reason"], +            "Created": created,          }          try: @@ -384,14 +391,19 @@ class InfractionScheduler(Scheduler):          if send_log:              log_title = f"expiration failed" if "Failure" in log_text else "expired" +            user = self.bot.get_user(user_id) +            avatar = user.avatar_url_as(static_format="png") if user else None +              log.trace(f"Sending deactivation mod log for infraction #{id_}.")              await self.mod_log.send_log_message(                  icon_url=utils.INFRACTION_ICONS[type_][1],                  colour=Colours.soft_green,                  title=f"Infraction {log_title}: {type_}", +                thumbnail=avatar,                  text="\n".join(f"{k}: {v}" for k, v in log_text.items()),                  footer=f"ID: {id_}",                  content=log_content, +              )          return log_text diff --git a/bot/cogs/site.py b/bot/cogs/site.py index 2ea8c7a2e..853e29568 100644 --- a/bot/cogs/site.py +++ b/bot/cogs/site.py @@ -59,7 +59,7 @@ class Site(Cog):      @site_group.command(name="tools")      async def site_tools(self, ctx: Context) -> None:          """Info about the site's Tools page.""" -        tools_url = f"{PAGES_URL}/tools" +        tools_url = f"{PAGES_URL}/resources/tools"          embed = Embed(title="Tools")          embed.set_footer(text=f"{tools_url}") @@ -74,7 +74,7 @@ class Site(Cog):      @site_group.command(name="help")      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"{PAGES_URL}/resources/guides/asking-good-questions"          embed = Embed(title="Asking Good Questions")          embed.set_footer(text=url) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index 970301013..54a51921c 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -1,5 +1,7 @@  import logging +import re  import time +from typing import Dict, List, Optional  from discord import Colour, Embed  from discord.ext.commands import Cog, Context, group @@ -10,7 +12,6 @@ from bot.converters import TagContentConverter, TagNameConverter  from bot.decorators import with_role  from bot.pagination import LinePaginator -  log = logging.getLogger(__name__)  TEST_CHANNELS = ( @@ -19,6 +20,8 @@ TEST_CHANNELS = (      Channels.helpers  ) +REGEX_NON_ALPHABET = re.compile(r"[^a-z]", re.MULTILINE & re.IGNORECASE) +  class Tags(Cog):      """Save new tags and fetch existing tags.""" @@ -27,6 +30,63 @@ class Tags(Cog):          self.bot = bot          self.tag_cooldowns = {} +        self._cache = {} +        self._last_fetch: float = 0.0 + +    async def _get_tags(self, is_forced: bool = False) -> None: +        """Get all tags.""" +        # refresh only when there's a more than 5m gap from last call. +        time_now: float = time.time() +        if is_forced or not self._last_fetch or time_now - self._last_fetch > 5 * 60: +            tags = await self.bot.api_client.get('bot/tags') +            self._cache = {tag['title'].lower(): tag for tag in tags} +            self._last_fetch = time_now + +    @staticmethod +    def _fuzzy_search(search: str, target: str) -> int: +        """A simple scoring algorithm based on how many letters are found / total, with order in mind.""" +        current, index = 0, 0 +        _search = REGEX_NON_ALPHABET.sub('', search.lower()) +        _targets = iter(REGEX_NON_ALPHABET.split(target.lower())) +        _target = next(_targets) +        try: +            while True: +                while index < len(_target) and _search[current] == _target[index]: +                    current += 1 +                    index += 1 +                index, _target = 0, next(_targets) +        except (StopIteration, IndexError): +            pass +        return current / len(_search) * 100 + +    def _get_suggestions(self, tag_name: str, thresholds: Optional[List[int]] = None) -> List[str]: +        """Return a list of suggested tags.""" +        scores: Dict[str, int] = { +            tag_title: Tags._fuzzy_search(tag_name, tag['title']) +            for tag_title, tag in self._cache.items() +        } + +        thresholds = thresholds or [100, 90, 80, 70, 60] + +        for threshold in thresholds: +            suggestions = [ +                self._cache[tag_title] +                for tag_title, matching_score in scores.items() +                if matching_score >= threshold +            ] +            if suggestions: +                return suggestions + +        return [] + +    async def _get_tag(self, tag_name: str) -> list: +        """Get a specific tag.""" +        await self._get_tags() +        found = [self._cache.get(tag_name.lower(), None)] +        if not found[0]: +            return self._get_suggestions(tag_name) +        return found +      @group(name='tags', aliases=('tag', 't'), invoke_without_command=True)      async def tags_group(self, ctx: Context, *, tag_name: TagNameConverter = None) -> None:          """Show all known tags, a single tag, or run a subcommand.""" @@ -60,17 +120,27 @@ class Tags(Cog):                          f"Cooldown ends in {time_left:.1f} seconds.")              return +        await self._get_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'])) +            founds = await self._get_tag(tag_name) + +            if len(founds) == 1: +                tag = founds[0] +                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'])) +            elif founds and len(tag_name) >= 3: +                await ctx.send(embed=Embed( +                    title='Did you mean ...', +                    description='\n'.join(tag['title'] for tag in founds[:10]) +                ))          else: -            tags = await self.bot.api_client.get('bot/tags') +            tags = self._cache.values()              if not tags:                  await ctx.send(embed=Embed(                      description="**There are no tags in the database!**", @@ -106,6 +176,7 @@ class Tags(Cog):          }          await self.bot.api_client.post('bot/tags', json=body) +        self._cache[tag_name.lower()] = await self.bot.api_client.get(f'bot/tags/{tag_name}')          log.debug(f"{ctx.author} successfully added the following tag to our database: \n"                    f"tag_name: {tag_name}\n" @@ -135,6 +206,7 @@ class Tags(Cog):          }          await self.bot.api_client.patch(f'bot/tags/{tag_name}', json=body) +        self._cache[tag_name.lower()] = await self.bot.api_client.get(f'bot/tags/{tag_name}')          log.debug(f"{ctx.author} successfully edited the following tag in our database: \n"                    f"tag_name: {tag_name}\n" @@ -151,6 +223,7 @@ class Tags(Cog):      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}') +        self._cache.pop(tag_name.lower(), None)          log.debug(f"{ctx.author} successfully deleted the tag called '{tag_name}'")          await ctx.send(embed=Embed( diff --git a/bot/constants.py b/bot/constants.py index 25c7856ba..a4c65a1f8 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -193,7 +193,7 @@ class Bot(metaclass=YAMLGetter):      prefix: str      token: str - +    sentry_dsn: str  class Filter(metaclass=YAMLGetter):      section = "filter" @@ -257,6 +257,7 @@ class Emojis(metaclass=YAMLGetter):      status_dnd: str      failmail: str +    trashcan: str      bullet: str      new: str @@ -359,6 +360,7 @@ class Channels(metaclass=YAMLGetter):      admins: int      admin_spam: int      announcements: int +    attachment_log: int      big_brother_logs: int      bot: int      checkpoint_test: int diff --git a/bot/pagination.py b/bot/pagination.py index 76082f459..e82763912 100644 --- a/bot/pagination.py +++ b/bot/pagination.py @@ -6,11 +6,13 @@ from discord import Embed, Member, Message, Reaction  from discord.abc import User  from discord.ext.commands import Context, Paginator +from bot import constants +  FIRST_EMOJI = "\u23EE"   # [:track_previous:]  LEFT_EMOJI = "\u2B05"    # [:arrow_left:]  RIGHT_EMOJI = "\u27A1"   # [:arrow_right:]  LAST_EMOJI = "\u23ED"    # [:track_next:] -DELETE_EMOJI = "\u274c"  # [:x:] +DELETE_EMOJI = constants.Emojis.trashcan  # [:trashcan:]  PAGINATION_EMOJI = [FIRST_EMOJI, LEFT_EMOJI, RIGHT_EMOJI, LAST_EMOJI, DELETE_EMOJI] @@ -131,7 +133,7 @@ class LinePaginator(Paginator):                      # Reaction is on this message                      reaction_.message.id == message.id,                      # Reaction is one of the pagination emotes -                    reaction_.emoji in PAGINATION_EMOJI, +                    str(reaction_.emoji) in PAGINATION_EMOJI,                      # Reaction was not made by the Bot                      user_.id != ctx.bot.user.id,                      # There were no restrictions @@ -203,9 +205,9 @@ class LinePaginator(Paginator):                  log.debug("Timed out waiting for a reaction")                  break  # We're done, no reactions for the last 5 minutes -            if reaction.emoji == DELETE_EMOJI: +            if str(reaction.emoji) == DELETE_EMOJI:                  log.debug("Got delete reaction") -                break +                return await message.delete()              if reaction.emoji == FIRST_EMOJI:                  await message.remove_reaction(reaction.emoji, user) @@ -342,7 +344,7 @@ class ImagePaginator(Paginator):                  # Reaction is on the same message sent                  reaction_.message.id == message.id,                  # The reaction is part of the navigation menu -                reaction_.emoji in PAGINATION_EMOJI, +                str(reaction_.emoji) in PAGINATION_EMOJI,                  # The reactor is not a bot                  not member.bot              )) @@ -388,10 +390,10 @@ class ImagePaginator(Paginator):              # Deletes the users reaction              await message.remove_reaction(reaction.emoji, user) -            # Delete reaction press - [:x:] -            if reaction.emoji == DELETE_EMOJI: +            # Delete reaction press - [:trashcan:] +            if str(reaction.emoji) == DELETE_EMOJI:                  log.debug("Got delete reaction") -                break +                return await message.delete()              # First reaction press - [:track_previous:]              if reaction.emoji == FIRST_EMOJI: @@ -408,7 +410,7 @@ class ImagePaginator(Paginator):                      log.debug("Got last page reaction, but we're on the last page - ignoring")                      continue -                current_page = len(paginator.pages - 1) +                current_page = len(paginator.pages) - 1                  reaction_type = "last"              # Previous reaction press - [:arrow_left: ] diff --git a/bot/utils/messages.py b/bot/utils/messages.py index 549b33ca6..a36edc774 100644 --- a/bot/utils/messages.py +++ b/bot/utils/messages.py @@ -1,7 +1,8 @@  import asyncio  import contextlib +import logging  from io import BytesIO -from typing import Optional, Sequence, Union +from typing import List, Optional, Sequence, Union  from discord import Client, Embed, File, Member, Message, Reaction, TextChannel, Webhook  from discord.abc import Snowflake @@ -9,13 +10,13 @@ from discord.errors import HTTPException  from bot.constants import Emojis -MAX_SIZE = 1024 * 1024 * 8  # 8 Mebibytes +log = logging.getLogger(__name__)  async def wait_for_deletion(      message: Message,      user_ids: Sequence[Snowflake], -    deletion_emojis: Sequence[str] = (Emojis.cross_mark,), +    deletion_emojis: Sequence[str] = (Emojis.trashcan,),      timeout: float = 60 * 5,      attach_emojis: bool = True,      client: Optional[Client] = None @@ -39,10 +40,10 @@ async def wait_for_deletion(              await message.add_reaction(emoji)      def check(reaction: Reaction, user: Member) -> bool: -        """Check that the deletion emoji is reacted by the approprite user.""" +        """Check that the deletion emoji is reacted by the appropriate user."""          return (              reaction.message.id == message.id -            and reaction.emoji in deletion_emojis +            and str(reaction.emoji) in deletion_emojis              and user.id in user_ids          ) @@ -51,42 +52,58 @@ async def wait_for_deletion(          await message.delete() -async def send_attachments(message: Message, destination: Union[TextChannel, Webhook]) -> None: +async def send_attachments( +    message: Message, +    destination: Union[TextChannel, Webhook], +    link_large: bool = True +) -> List[str]:      """ -    Re-uploads each attachment in a message to the given channel or webhook. +    Re-upload the message's attachments to the destination and return a list of their new URLs. -    Each attachment is sent as a separate message to more easily comply with the 8 MiB request size limit. -    If attachments are too large, they are instead grouped into a single embed which links to them. +    Each attachment is sent as a separate message to more easily comply with the request/file size +    limit. If link_large is True, attachments which are too large are instead grouped into a single +    embed which links to them.      """      large = [] +    urls = []      for attachment in message.attachments: +        failure_msg = ( +            f"Failed to re-upload attachment {attachment.filename} from message {message.id}" +        ) +          try: -            # This should avoid most files that are too large, but some may get through hence the try-catch.              # Allow 512 bytes of leeway for the rest of the request. -            if attachment.size <= MAX_SIZE - 512: +            # This should avoid most files that are too large, +            # but some may get through hence the try-catch. +            if attachment.size <= destination.guild.filesize_limit - 512:                  with BytesIO() as file: -                    await attachment.save(file) +                    await attachment.save(file, use_cached=True)                      attachment_file = File(file, filename=attachment.filename)                      if isinstance(destination, TextChannel): -                        await destination.send(file=attachment_file) +                        msg = await destination.send(file=attachment_file) +                        urls.append(msg.attachments[0].url)                      else:                          await destination.send(                              file=attachment_file,                              username=message.author.display_name,                              avatar_url=message.author.avatar_url                          ) -            else: +            elif link_large:                  large.append(attachment) +            else: +                log.warning(f"{failure_msg} because it's too large.")          except HTTPException as e: -            if e.status == 413: +            if link_large and e.status == 413:                  large.append(attachment)              else: -                raise +                log.warning(f"{failure_msg} with status {e.status}.") -    if large: -        embed = Embed(description=f"\n".join(f"[{attachment.filename}]({attachment.url})" for attachment in large)) +    if link_large and large: +        desc = f"\n".join(f"[{attachment.filename}]({attachment.url})" for attachment in large) +        embed = Embed(description=desc)          embed.set_footer(text="Attachments exceed upload size limit.") +          if isinstance(destination, TextChannel):              await destination.send(embed=embed)          else: @@ -95,3 +112,5 @@ async def send_attachments(message: Message, destination: Union[TextChannel, Web                  username=message.author.display_name,                  avatar_url=message.author.avatar_url              ) + +    return urls diff --git a/bot/utils/time.py b/bot/utils/time.py index 7416f36e0..77060143c 100644 --- a/bot/utils/time.py +++ b/bot/utils/time.py @@ -114,30 +114,40 @@ def format_infraction(timestamp: str) -> str:  def format_infraction_with_duration( -    expiry: Optional[str], +    date_to: Optional[str],      date_from: Optional[datetime.datetime] = None, -    max_units: int = 2 +    max_units: int = 2, +    absolute: bool = True  ) -> Optional[str]:      """ -    Format an infraction timestamp to a more readable ISO 8601 format WITH the duration. +    Return `date_to` formatted as a readable ISO-8601 with the humanized duration since `date_from`. -    Returns a human-readable version of the duration between datetime.utcnow() and an expiry. -    Unlike `humanize_delta`, this function will force the `precision` to be `seconds` by not passing it. -    `max_units` specifies the maximum number of units of time to include (e.g. 1 may include days but not hours). -    By default, max_units is 2. +    `date_from` must be an ISO-8601 formatted timestamp. The duration is calculated as from +    `date_from` until `date_to` with a precision of seconds. If `date_from` is unspecified, the +    current time is used. + +    `max_units` specifies the maximum number of units of time to include in the duration. For +    example, a value of 1 may include days but not hours. + +    If `absolute` is True, the absolute value of the duration delta is used. This prevents negative +    values in the case that `date_to` is in the past relative to `date_from`.      """ -    if not expiry: +    if not date_to:          return None +    date_to_formatted = format_infraction(date_to) +      date_from = date_from or datetime.datetime.utcnow() -    date_to = dateutil.parser.isoparse(expiry).replace(tzinfo=None, microsecond=0) +    date_to = dateutil.parser.isoparse(date_to).replace(tzinfo=None, microsecond=0) -    expiry_formatted = format_infraction(expiry) +    delta = relativedelta(date_to, date_from) +    if absolute: +        delta = abs(delta) -    duration = humanize_delta(relativedelta(date_to, date_from), max_units=max_units) -    duration_formatted = f" ({duration})" if duration else '' +    duration = humanize_delta(delta, max_units=max_units) +    duration_formatted = f" ({duration})" if duration else "" -    return f"{expiry_formatted}{duration_formatted}" +    return f"{date_to_formatted}{duration_formatted}"  def until_expiration( diff --git a/config-default.yml b/config-default.yml index 1a8aaedae..2eaf8ee06 100644 --- a/config-default.yml +++ b/config-default.yml @@ -1,6 +1,7 @@  bot:      prefix:      "!"      token:       !ENV "BOT_TOKEN" +    sentry_dsn:  !ENV "BOT_SENTRY_DSN"      cooldowns:          # Per channel, per tag. @@ -28,6 +29,7 @@ style:          status_offline: "<:status_offline:470326266537705472>"          failmail: "<:failmail:633660039931887616>" +        trashcan: "<:trashcan:637136429717389331>"          bullet:     "\u2022"          pencil:     "\u270F" @@ -115,6 +117,7 @@ guild:          admin_spam:        &ADMIN_SPAM    563594791770914816          admins_voice:      &ADMINS_VOICE  500734494840717332          announcements:                    354619224620138496 +        attachment_log:    &ATTCH_LOG     649243850006855680          big_brother_logs:  &BBLOGS        468507907357409333          bot:                              267659945086812160          checkpoint_test:                  422077681434099723 @@ -152,7 +155,7 @@ guild:          voice_log:                        640292421988646961      staff_channels: [*ADMINS, *ADMIN_SPAM, *MOD_SPAM, *MODS, *HELPERS, *ORGANISATION, *DEFCON] -    ignored: [*ADMINS, *MESSAGE_LOG, *MODLOG, *ADMINS_VOICE, *STAFF_VOICE] +    ignored: [*ADMINS, *MESSAGE_LOG, *MODLOG, *ADMINS_VOICE, *STAFF_VOICE, *ATTCH_LOG]      roles:          admin:             &ADMIN_ROLE      267628507062992896 @@ -300,7 +303,7 @@ urls:      paste_service:                      !JOIN [*SCHEMA, *PASTE, "/{key}"]      # Snekbox -    snekbox_eval_api: "https://snekbox.pythondiscord.com/eval" +    snekbox_eval_api: "http://snekbox:8060/eval"      # Discord API URLs      discord_api:        &DISCORD_API "https://discordapp.com/api/v7/" diff --git a/tests/bot/test_api.py b/tests/bot/test_api.py index 5a88adc5c..bdfcc73e4 100644 --- a/tests/bot/test_api.py +++ b/tests/bot/test_api.py @@ -1,9 +1,7 @@ -import logging  import unittest -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock  from bot import api -from tests.base import LoggingTestCase  from tests.helpers import async_test @@ -34,7 +32,7 @@ class APIClientTests(unittest.TestCase):          self.assertEqual(error.response_text, "")          self.assertIs(error.response, self.error_api_response) -    def test_responde_code_error_string_representation_default_initialization(self): +    def test_response_code_error_string_representation_default_initialization(self):          """Test the string representation of `ResponseCodeError` initialized without text or json."""          error = api.ResponseCodeError(response=self.error_api_response)          self.assertEqual(str(error), f"Status: {self.error_api_response.status} Response: ") @@ -76,61 +74,3 @@ class APIClientTests(unittest.TestCase):              response_text=text_data          )          self.assertEqual(str(error), f"Status: {self.error_api_response.status} Response: {text_data}") - - -class LoggingHandlerTests(LoggingTestCase): -    """Tests the bot's API Log Handler.""" - -    @classmethod -    def setUpClass(cls): -        cls.debug_log_record = logging.LogRecord( -            name='my.logger', level=logging.DEBUG, -            pathname='my/logger.py', lineno=666, -            msg="Lemon wins", args=(), -            exc_info=None -        ) - -        cls.trace_log_record = logging.LogRecord( -            name='my.logger', level=logging.TRACE, -            pathname='my/logger.py', lineno=666, -            msg="This will not be logged", args=(), -            exc_info=None -        ) - -    def setUp(self): -        self.log_handler = api.APILoggingHandler(None) - -    def test_emit_appends_to_queue_with_stopped_event_loop(self): -        """Test if `APILoggingHandler.emit` appends to queue when the event loop is not running.""" -        with patch("bot.api.APILoggingHandler.ship_off") as ship_off: -            # Patch `ship_off` to ease testing against the return value of this coroutine. -            ship_off.return_value = 42 -            self.log_handler.emit(self.debug_log_record) - -        self.assertListEqual(self.log_handler.queue, [42]) - -    def test_emit_ignores_less_than_debug(self): -        """`APILoggingHandler.emit` should not queue logs with a log level lower than DEBUG.""" -        self.log_handler.emit(self.trace_log_record) -        self.assertListEqual(self.log_handler.queue, []) - -    def test_schedule_queued_tasks_for_empty_queue(self): -        """`APILoggingHandler` should not schedule anything when the queue is empty.""" -        with self.assertNotLogs(level=logging.DEBUG): -            self.log_handler.schedule_queued_tasks() - -    def test_schedule_queued_tasks_for_nonempty_queue(self): -        """`APILoggingHandler` should schedule logs when the queue is not empty.""" -        log = logging.getLogger("bot.api") - -        with self.assertLogs(logger=log, level=logging.DEBUG) as logs, patch('asyncio.create_task') as create_task: -            self.log_handler.queue = [555] -            self.log_handler.schedule_queued_tasks() -            self.assertListEqual(self.log_handler.queue, []) -            create_task.assert_called_once_with(555) - -            [record] = logs.records -            self.assertEqual(record.message, "Scheduled 1 pending logging tasks.") -            self.assertEqual(record.levelno, logging.DEBUG) -            self.assertEqual(record.name, 'bot.api') -            self.assertIn('via_handler', record.__dict__) @@ -3,7 +3,7 @@ max-line-length=120  docstring-convention=all  import-order-style=pycharm  application_import_names=bot,tests -exclude=.cache,.venv,constants.py +exclude=.cache,.venv,.git,constants.py  ignore=      B311,W503,E226,S311,T000      # Missing Docstrings @@ -15,5 +15,5 @@ ignore=      # 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 +    ANN002,ANN003,ANN101,ANN102,ANN204,ANN206 +per-file-ignores=tests/*:D,ANN | 
