diff options
50 files changed, 2692 insertions, 2494 deletions
diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..864b4e557 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,35 @@ +FROM python:3.7-alpine3.7 + +RUN apk add --no-cache \ + build-base \ + freetype-dev \ + git \ + jpeg-dev \ + libffi-dev \ + libxml2 \ + libxml2-dev \ + libxslt-dev \ + tini \ + zlib \ + zlib-dev + +ENV \ + LIBRARY_PATH=/lib:/usr/lib \ + PIPENV_HIDE_EMOJIS=1 \ + PIPENV_HIDE_EMOJIS=1 \ + PIPENV_IGNORE_VIRTUALENVS=1 \ + PIPENV_IGNORE_VIRTUALENVS=1 \ + PIPENV_NOSPIN=1 \ + PIPENV_NOSPIN=1 \ + PIPENV_VENV_IN_PROJECT=1 \ + PIPENV_VENV_IN_PROJECT=1 + +RUN pip install -U pipenv + +WORKDIR /bot +COPY . . + +RUN pipenv install --deploy --system + +ENTRYPOINT ["/sbin/tini", "--"] +CMD ["pipenv", "run", "start"] @@ -17,6 +17,7 @@ aio-pika = "*" python-dateutil = "*" deepdiff = "*" requests = "*" +dateparser = "*" urllib3 = ">=1.24.2,<1.25" [dev-packages] @@ -29,9 +30,10 @@ urllib3 = ">=1.24.2,<1.25" safety = "*" dodgy = "*" pre-commit = "*" +pytest = "*" [requires] -python_version = "3.6" +python_version = "3.7" [scripts] start = "python -m bot" diff --git a/Pipfile.lock b/Pipfile.lock index 735d7cd96..e2585756f 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,11 +1,11 @@ { "_meta": { "hash": { - "sha256": "ab3b63b74dbf35fb960913a91e10282121a2776e935d98f0b4c3d780715f7a6b" + "sha256": "ad3b645e777f7b21a2bfb472e182361f904ae5f1f41df59300c4c68c89bd2fd1" }, "pipfile-spec": 6, "requires": { - "python_version": "3.6" + "python_version": "3.7" }, "sources": [ { @@ -18,11 +18,11 @@ "default": { "aio-pika": { "hashes": [ - "sha256:300474d8b0e9ccde17b2d1e71c3b4f7ba86559cc0842b9355b9eccb12be4a02a", - "sha256:3bc547600344beba8f36edfd1b1ec1c8b30f803ea7c11eaf249683099d07c98b" + "sha256:47b12535897117b9876db2e2c0506b6c89bd4dbd90617cd8b20163d4196137ed", + "sha256:821ee9f652ba472919ebffdc37c661fc740c24309a2291b37ac8a160b4003ce6" ], "index": "pypi", - "version": "==5.5.2" + "version": "==5.5.3" }, "aiodns": { "hashes": [ @@ -62,10 +62,10 @@ }, "aiormq": { "hashes": [ - "sha256:79b41e51481fb7617279414e4428a644a944beb4dea8ea0febd67a8902976250", - "sha256:f134cc91ac111b0135c97539272579b1d15b69f25c75a935f6ee39e5194df231" + "sha256:038bd43d68f8e77bf79c7cc362da9df5ca6497c23c3bf20ee43ce1622448ef8a", + "sha256:f36be480de4009ddb621a8795c52f0c146813799f56d79e126dfa60e13e41dd9" ], - "version": "==2.5.2" + "version": "==2.5.5" }, "alabaster": { "hashes": [ @@ -90,10 +90,10 @@ }, "babel": { "hashes": [ - "sha256:6778d85147d5d85345c14a26aada5e478ab04e39b078b0745ee6870c2b5cf669", - "sha256:8cba50f48c529ca3fa18cf81fa9403be176d374ac4d60738b839122dfaaa3d23" + "sha256:af92e6106cb7c55286b25b38ad7695f8b4efb36a90ba483d7f7a6628c46158ab", + "sha256:e86135ae101e31e2c8ec20a4e0c5220f4eed12487d5cf3f78be7e98d3a57fc28" ], - "version": "==2.6.0" + "version": "==2.7.0" }, "beautifulsoup4": { "hashes": [ @@ -105,10 +105,10 @@ }, "certifi": { "hashes": [ - "sha256:59b7658e26ca9c7339e00f8f4636cdfe59d34fa37b9b04f6f9e9926b3cece1a5", - "sha256:b26104d6835d1f5e49452a26eb2ff87fe7090b89dfcaee5ea2212697e1e1d7ae" + "sha256:046832c04d4e752f37383b628bc601a7ea7211496b4638f6514d0e5b9acc4939", + "sha256:945e3ba63a0b9f577b1395204e13c3a231f9bc0223888be653286534e5873695" ], - "version": "==2019.3.9" + "version": "==2019.6.16" }, "cffi": { "hashes": [ @@ -150,6 +150,14 @@ ], "version": "==3.0.4" }, + "dateparser": { + "hashes": [ + "sha256:42d51be54e74a8e80a4d76d1fa6e4edd997098fce24ad2d94a2eab5ef247193e", + "sha256:78124c458c461ea7198faa3c038f6381f37588b84bb42740e91a4cbd260b1d09" + ], + "index": "pypi", + "version": "==0.7.1" + }, "deepdiff": { "hashes": [ "sha256:55e461f56dcae3dc540746b84434562fb7201e5c27ecf28800e4cfdd17f61e56", @@ -205,10 +213,10 @@ }, "jsonpickle": { "hashes": [ - "sha256:0231d6f7ebc4723169310141352d9c9b7bbbd6f3be110cf634575d2bf2af91f0", - "sha256:625098cc8e5854b8c23b587aec33bc8e33e0e597636bfaca76152249c78fe5c1" + "sha256:d0c5a4e6cb4e58f6d5406bdded44365c2bcf9c836c4f52910cc9ba7245a59dc2", + "sha256:d3e922d781b1d0096df2dad89a2e1f47177d7969b596aea806a9d91b4626b29b" ], - "version": "==1.1" + "version": "==1.2" }, "logmatic-python": { "hashes": [ @@ -219,35 +227,33 @@ }, "lxml": { "hashes": [ - "sha256:03984196d00670b2ab14ae0ea83d5cc0cfa4f5a42558afa9ab5fa745995328f5", - "sha256:0815b0c9f897468de6a386dc15917a0becf48cc92425613aa8bbfc7f0f82951f", - "sha256:175f3825f075cf02d15099eb52658457cf0ff103dcf11512b5d2583e1d40f58b", - "sha256:30e14c62d88d1e01a26936ecd1c6e784d4afc9aa002bba4321c5897937112616", - "sha256:3210da6f36cf4b835ff1be853962b22cc354d506f493b67a4303c88bbb40d57b", - "sha256:40f60819fbd5bad6e191ba1329bfafa09ab7f3f174b3d034d413ef5266963294", - "sha256:43b26a865a61549919f8a42e094dfdb62847113cf776d84bd6b60e4e3fc20ea3", - "sha256:4a03dd682f8e35a10234904e0b9508d705ff98cf962c5851ed052e9340df3d90", - "sha256:62f382cddf3d2e52cf266e161aa522d54fd624b8cc567bc18f573d9d50d40e8e", - "sha256:7b98f0325be8450da70aa4a796c4f06852949fe031878b4aa1d6c417a412f314", - "sha256:846a0739e595871041385d86d12af4b6999f921359b38affb99cdd6b54219a8f", - "sha256:a3080470559938a09a5d0ec558c005282e99ac77bf8211fb7b9a5c66390acd8d", - "sha256:ad841b78a476623955da270ab8d207c3c694aa5eba71f4792f65926dc46c6ee8", - "sha256:afdd75d9735e44c639ffd6258ce04a2de3b208f148072c02478162d0944d9da3", - "sha256:b4fbf9b552faff54742bcd0791ab1da5863363fb19047e68f6592be1ac2dab33", - "sha256:b90c4e32d6ec089d3fa3518436bdf5ce4d902a0787dbd9bb09f37afe8b994317", - "sha256:b91cfe4438c741aeff662d413fd2808ac901cc6229c838236840d11de4586d63", - "sha256:bdb0593a42070b0a5f138b79b872289ee73c8e25b3f0bea6564e795b55b6bcdd", - "sha256:c4e4bca2bb68ce22320297dfa1a7bf070a5b20bcbaec4ee023f83d2f6e76496f", - "sha256:cec4ab14af9eae8501be3266ff50c3c2aecc017ba1e86c160209bb4f0423df6a", - "sha256:e83b4b2bf029f5104bc1227dbb7bf5ace6fd8fabaebffcd4f8106fafc69fc45f", - "sha256:e995b3734a46d41ae60b6097f7c51ba9958648c6d1e0935b7e0ee446ee4abe22", - "sha256:f679d93dec7f7210575c85379a31322df4c46496f184ef650d3aba1484b38a2d", - "sha256:fd213bb5166e46974f113c8228daaef1732abc47cb561ce9c4c8eaed4bd3b09b", - "sha256:fdcb57b906dbc1f80666e6290e794ab8fb959a2e17aa5aee1758a85d1da4533f", - "sha256:ff424b01d090ffe1947ec7432b07f536912e0300458f9a7f48ea217dd8362b86" + "sha256:06c7616601430aa140a69f97e3116308fffe0848f543b639a5ec2e8920ae72fd", + "sha256:177202792f9842374a8077735c69c41a4282183f7851443d2beb8ee310720819", + "sha256:19317ad721ceb9e39847d11131903931e2794e447d4751ebb0d9236f1b349ff2", + "sha256:36d206e62f3e5dbaafd4ec692b67157e271f5da7fd925fda8515da675eace50d", + "sha256:387115b066c797c85f9861a9613abf50046a15aac16759bc92d04f94acfad082", + "sha256:3ce1c49d4b4a7bc75fb12acb3a6247bb7a91fe420542e6d671ba9187d12a12c2", + "sha256:4d2a5a7d6b0dbb8c37dab66a8ce09a8761409c044017721c21718659fa3365a1", + "sha256:58d0a1b33364d1253a88d18df6c0b2676a1746d27c969dc9e32d143a3701dda5", + "sha256:62a651c618b846b88fdcae0533ec23f185bb322d6c1845733f3123e8980c1d1b", + "sha256:69ff21064e7debc9b1b1e2eee8c2d686d042d4257186d70b338206a80c5bc5ea", + "sha256:7060453eba9ba59d821625c6af6a266bd68277dce6577f754d1eb9116c094266", + "sha256:7d26b36a9c4bce53b9cfe42e67849ae3c5c23558bc08363e53ffd6d94f4ff4d2", + "sha256:83b427ad2bfa0b9705e02a83d8d607d2c2f01889eb138168e462a3a052c42368", + "sha256:923d03c84534078386cf50193057aae98fa94cace8ea7580b74754493fda73ad", + "sha256:b773715609649a1a180025213f67ffdeb5a4878c784293ada300ee95a1f3257b", + "sha256:baff149c174e9108d4a2fee192c496711be85534eab63adb122f93e70aa35431", + "sha256:bca9d118b1014b4c2d19319b10a3ebed508ff649396ce1855e1c96528d9b2fa9", + "sha256:ce580c28845581535dc6000fc7c35fdadf8bea7ccb57d6321b044508e9ba0685", + "sha256:d34923a569e70224d88e6682490e24c842907ba2c948c5fd26185413cbe0cd96", + "sha256:dd9f0e531a049d8b35ec5e6c68a37f1ba6ec3a591415e6804cbdf652793d15d7", + "sha256:ecb805cbfe9102f3fd3d2ef16dfe5ae9e2d7a7dfbba92f4ff1e16ac9784dbfb0", + "sha256:ede9aad2197a0202caff35d417b671f5f91a3631477441076082a17c94edd846", + "sha256:ef2d1fc370400e0aa755aab0b20cf4f1d0e934e7fd5244f3dd4869078e4942b9", + "sha256:f2fec194a49bfaef42a548ee657362af5c7a640da757f6f452a35da7dd9f923c" ], "index": "pypi", - "version": "==4.3.3" + "version": "==4.3.4" }, "markdownify": { "hashes": [ @@ -363,16 +369,17 @@ }, "pycparser": { "hashes": [ - "sha256:a988718abfad80b6b157acce7bf130a30876d27603738ac39f140993246b25b3" + "sha256:a988718abfad80b6b157acce7bf130a30876d27603738ac39f140993246b25b3", + "sha256:b360ff0cd21cdecd07372020a2d7f3234e1acc8c31ab4b4d3a6fa6e5bc6259cd" ], "version": "==2.19" }, "pygments": { "hashes": [ - "sha256:5ffada19f6203563680669ee7f53b64dabbeb100eb51b61996085e99c03b284a", - "sha256:e8218dd399a61674745138520d0d4cf2621d7e032439341bc3f647bff125818d" + "sha256:71e430bc85c88a430f000ac1d9b331d2407f681d6f6aec95e8bcfbc3df5b0127", + "sha256:881c4c157e45f30af185c1ffe8d549d48ac9127433f2c380c24b84572ad66297" ], - "version": "==2.3.1" + "version": "==2.4.2" }, "pynacl": { "hashes": [ @@ -440,28 +447,44 @@ }, "pyyaml": { "hashes": [ - "sha256:1adecc22f88d38052fb787d959f003811ca858b799590a5eaa70e63dca50308c", - "sha256:436bc774ecf7c103814098159fbb84c2715d25980175292c648f2da143909f95", - "sha256:460a5a4248763f6f37ea225d19d5c205677d8d525f6a83357ca622ed541830c2", - "sha256:5a22a9c84653debfbf198d02fe592c176ea548cccce47553f35f466e15cf2fd4", - "sha256:7a5d3f26b89d688db27822343dfa25c599627bc92093e788956372285c6298ad", - "sha256:9372b04a02080752d9e6f990179a4ab840227c6e2ce15b95e1278456664cf2ba", - "sha256:a5dcbebee834eaddf3fa7366316b880ff4062e4bcc9787b78c7fbb4a26ff2dd1", - "sha256:aee5bab92a176e7cd034e57f46e9df9a9862a71f8f37cad167c6fc74c65f5b4e", - "sha256:c51f642898c0bacd335fc119da60baae0824f2cde95b0330b56c0553439f0673", - "sha256:c68ea4d3ba1705da1e0d85da6684ac657912679a649e8868bd850d2c299cce13", - "sha256:e23d0cc5299223dcc37885dae624f382297717e459ea24053709675a976a3e19" + "sha256:57acc1d8533cbe51f6662a55434f0dbecfa2b9eaf115bede8f6fd00115a0c0d3", + "sha256:588c94b3d16b76cfed8e0be54932e5729cc185caffaa5a451e7ad2f7ed8b4043", + "sha256:68c8dd247f29f9a0d09375c9c6b8fdc64b60810ebf07ba4cdd64ceee3a58c7b7", + "sha256:70d9818f1c9cd5c48bb87804f2efc8692f1023dac7f1a1a5c61d454043c1d265", + "sha256:86a93cccd50f8c125286e637328ff4eef108400dd7089b46a7be3445eecfa391", + "sha256:a0f329125a926876f647c9fa0ef32801587a12328b4a3c741270464e3e4fa778", + "sha256:a3c252ab0fa1bb0d5a3f6449a4826732f3eb6c0270925548cac342bc9b22c225", + "sha256:b4bb4d3f5e232425e25dda21c070ce05168a786ac9eda43768ab7f3ac2770955", + "sha256:cd0618c5ba5bda5f4039b9398bb7fb6a317bb8298218c3de25c47c4740e4b95e", + "sha256:ceacb9e5f8474dcf45b940578591c7f3d960e82f926c707788a570b51ba59190", + "sha256:fe6a88094b64132c4bb3b631412e90032e8cfe9745a58370462240b8cb7553cd" ], "index": "pypi", - "version": "==5.1" + "version": "==5.1.1" + }, + "regex": { + "hashes": [ + "sha256:1c70ccb8bf4ded0cbe53092e9f56dcc9d6b0efcf6e80b6ef9b0ece8a557d6635", + "sha256:2948310c01535ccb29bb600dd033b07b91f36e471953889b7f3a1e66b39d0c19", + "sha256:2ab13db0411cb308aa590d33c909ea4efeced40188d8a4a7d3d5970657fe73bc", + "sha256:38e6486c7e14683cd1b17a4218760f0ea4c015633cf1b06f7c190fb882a51ba7", + "sha256:80dde4ff10b73b823da451687363cac93dd3549e059d2dc19b72a02d048ba5aa", + "sha256:84daedefaa56320765e9c4d43912226d324ef3cc929f4d75fa95f8c579a08211", + "sha256:b98e5876ca1e63b41c4aa38d7d5cc04a736415d4e240e9ae7ebc4f780083c7d5", + "sha256:ca4f47131af28ef168ff7c80d4b4cad019cb4cabb5fa26143f43aa3dbd60389c", + "sha256:cf7838110d3052d359da527372666429b9485ab739286aa1a11ed482f037a88c", + "sha256:dd4e8924915fa748e128864352875d3d0be5f4597ab1b1d475988b8e3da10dd7", + "sha256:f2c65530255e4010a5029eb11138f5ecd5aa70363f57a3444d83b3253b0891be" + ], + "version": "==2019.6.8" }, "requests": { "hashes": [ - "sha256:502a824f31acdacb3a35b6690b5fbf0bc41d63a24a45c4004352b0242707598e", - "sha256:7bf2a778576d825600030a110f3c0e3e8edc51dfaafe1c146e39a2027784957b" + "sha256:11e007a8a2aa0323f5a921e9e6a2d7e4e67d9877e85773fba9ba6419025cbeb4", + "sha256:9cf5292fcd0f598c671cfc1e0d7d1a7f13bb8085e9a590f48c010551dc6c4b31" ], "index": "pypi", - "version": "==2.21.0" + "version": "==2.22.0" }, "six": { "hashes": [ @@ -486,11 +509,11 @@ }, "sphinx": { "hashes": [ - "sha256:423280646fb37944dd3c85c58fb92a20d745793a9f6c511f59da82fa97cd404b", - "sha256:de930f42600a4fef993587633984cc5027dedba2464bcf00ddace26b40f8d9ce" + "sha256:15143166e786c7faa76fa990d3b6b38ebffe081ef81cffd1d656b07f3b28a1fa", + "sha256:5fd62ba64235d77a81554d47ff6b17578171b6dbbc992221e9ebc684898fff59" ], "index": "pypi", - "version": "==2.0.1" + "version": "==2.1.1" }, "sphinxcontrib-applehelp": { "hashes": [ @@ -534,13 +557,19 @@ ], "version": "==1.1.3" }, + "tzlocal": { + "hashes": [ + "sha256:4ebeb848845ac898da6519b9b31879cf13b6626f7184c496037b818e238f2c4e" + ], + "version": "==1.5.1" + }, "urllib3": { "hashes": [ - "sha256:4c291ca23bbb55c76518905869ef34bdd5f0e46af7afe6861e8375643ffee1a0", - "sha256:9a247273df709c4fedb38c711e44292304f73f39ab01beda9f6b9fc375669ac3" + "sha256:2393a695cd12afedd0dcb26fe5d50d0cf248e5a66f75dbd89a3d4eb333a61af4", + "sha256:a637e5fae88995b256e3409dc4d52c2e2e0ba32c42a6365fee8bbd2238de3cfb" ], "index": "pypi", - "version": "==1.24.2" + "version": "==1.24.3" }, "websockets": { "hashes": [ @@ -588,10 +617,17 @@ "develop": { "aspy.yaml": { "hashes": [ - "sha256:ae249074803e8b957c83fdd82a99160d0d6d26dff9ba81ba608b42eebd7d8cd3", - "sha256:c7390d79f58eb9157406966201abf26da0d56c07e0ff0deadc39c8f4dbc13482" + "sha256:463372c043f70160a9ec950c3f1e4c3a82db5fca01d334b6bc89c7164d744bdc", + "sha256:e7c742382eff2caed61f87a39d13f99109088e5e93f04d76eb8d4b28aa143f45" + ], + "version": "==1.3.0" + }, + "atomicwrites": { + "hashes": [ + "sha256:03472c30eb2c5d1ba9227e4c2ca66ab8287fbfbbda3888aa93dc2e28fc6811b4", + "sha256:75a9445bac02d8d058d5e1fe689654ba5a6556a1dfd8ce6ec55a0ed79866cfa6" ], - "version": "==1.2.0" + "version": "==1.3.0" }, "attrs": { "hashes": [ @@ -602,17 +638,17 @@ }, "certifi": { "hashes": [ - "sha256:59b7658e26ca9c7339e00f8f4636cdfe59d34fa37b9b04f6f9e9926b3cece1a5", - "sha256:b26104d6835d1f5e49452a26eb2ff87fe7090b89dfcaee5ea2212697e1e1d7ae" + "sha256:046832c04d4e752f37383b628bc601a7ea7211496b4638f6514d0e5b9acc4939", + "sha256:945e3ba63a0b9f577b1395204e13c3a231f9bc0223888be653286534e5873695" ], - "version": "==2019.3.9" + "version": "==2019.6.16" }, "cfgv": { "hashes": [ - "sha256:6e9f2feea5e84bc71e56abd703140d7a2c250fc5ba38b8702fd6a68ed4e3b2ef", - "sha256:e7f186d4a36c099a9e20b04ac3108bd8bb9b9257e692ce18c8c3764d5cb12172" + "sha256:32edbe09de6f4521224b87822103a8c16a614d31a894735f7a5b3bcf0eb3c37e", + "sha256:3bd31385cd2bebddbba8012200aaf15aa208539f1b33973759b4d02fc2148da5" ], - "version": "==1.6.0" + "version": "==2.0.0" }, "chardet": { "hashes": [ @@ -698,10 +734,10 @@ }, "identify": { "hashes": [ - "sha256:443f419ca6160773cbaf22dbb302b1e436a386f23129dbb5482b68a147c2eca9", - "sha256:bd7f15fe07112b713fb68fbdde3a34dd774d9062128f2c398104889f783f989d" + "sha256:0a11379b46d06529795442742a043dc2fa14cd8c995ae81d1febbc5f1c014c87", + "sha256:43a5d24ffdb07bc7e21faf68b08e9f526a1f41f0056073f480291539ef961dfd" ], - "version": "==1.4.2" + "version": "==1.4.5" }, "idna": { "hashes": [ @@ -712,10 +748,10 @@ }, "importlib-metadata": { "hashes": [ - "sha256:46fc60c34b6ed7547e2a723fc8de6dc2e3a1173f8423246b3ce497f064e9c3de", - "sha256:bc136180e961875af88b1ab85b4009f4f1278f8396a60526c0009f503a1a96ca" + "sha256:6dfd58dfe281e8d240937776065dd3624ad5469c835248219bd16cf2e12dbeb7", + "sha256:cb6ee23b46173539939964df59d3d72c3e0c1b5d54b84f1d8a7e912fe43612db" ], - "version": "==0.9" + "version": "==0.18" }, "mccabe": { "hashes": [ @@ -724,6 +760,14 @@ ], "version": "==0.6.1" }, + "more-itertools": { + "hashes": [ + "sha256:2112d2ca570bb7c3e53ea1a35cd5df42bb0fd10c45f0fb97178679c3c03d64c7", + "sha256:c3e4748ba1aad8dba30a4886b0b1a2004f9a863837b8654e7059eebf727afa5a" + ], + "markers": "python_version > '2.7'", + "version": "==7.0.0" + }, "nodeenv": { "hashes": [ "sha256:ad8259494cf1c9034539f6cced78a1da4840a4b157e23640bc4a0c0546b0cb7a" @@ -737,13 +781,27 @@ ], "version": "==19.0" }, + "pluggy": { + "hashes": [ + "sha256:0825a152ac059776623854c1543d65a4ad408eb3d33ee114dff91e57ec6ae6fc", + "sha256:b9817417e95936bf75d85d3f8767f7df6cdde751fc40aed3bb3074cbcb77757c" + ], + "version": "==0.12.0" + }, "pre-commit": { "hashes": [ - "sha256:2576a2776098f3902ef9540a84696e8e06bf18a337ce43a6a889e7fa5d26c4c5", - "sha256:82f2f2d657d7f9280de9f927ae56886d60b9ef7f3714eae92d12713cd9cb9e11" + "sha256:92e406d556190503630fd801958379861c94884693a032ba66629d0351fdccd4", + "sha256:cccc39051bc2457b0c0f7152a411f8e05e3ba2fe1a5613e4ee0833c1c1985ce3" ], "index": "pypi", - "version": "==1.15.2" + "version": "==1.17.0" + }, + "py": { + "hashes": [ + "sha256:64f65755aee5b381cea27766a3a147c3f15b9b6b9ac88676de66ba2ae36793fa", + "sha256:dc639b046a6e2cff5bbe40194ad65936d6ba360b52b3c3fe1d08a82dd50b5e53" + ], + "version": "==1.8.0" }, "pycodestyle": { "hashes": [ @@ -766,30 +824,38 @@ ], "version": "==2.4.0" }, + "pytest": { + "hashes": [ + "sha256:4a784f1d4f2ef198fe9b7aef793e9fa1a3b2f84e822d9b3a64a181293a572d45", + "sha256:926855726d8ae8371803f7b2e6ec0a69953d9c6311fa7c3b6c1b929ff92d27da" + ], + "index": "pypi", + "version": "==4.6.3" + }, "pyyaml": { "hashes": [ - "sha256:1adecc22f88d38052fb787d959f003811ca858b799590a5eaa70e63dca50308c", - "sha256:436bc774ecf7c103814098159fbb84c2715d25980175292c648f2da143909f95", - "sha256:460a5a4248763f6f37ea225d19d5c205677d8d525f6a83357ca622ed541830c2", - "sha256:5a22a9c84653debfbf198d02fe592c176ea548cccce47553f35f466e15cf2fd4", - "sha256:7a5d3f26b89d688db27822343dfa25c599627bc92093e788956372285c6298ad", - "sha256:9372b04a02080752d9e6f990179a4ab840227c6e2ce15b95e1278456664cf2ba", - "sha256:a5dcbebee834eaddf3fa7366316b880ff4062e4bcc9787b78c7fbb4a26ff2dd1", - "sha256:aee5bab92a176e7cd034e57f46e9df9a9862a71f8f37cad167c6fc74c65f5b4e", - "sha256:c51f642898c0bacd335fc119da60baae0824f2cde95b0330b56c0553439f0673", - "sha256:c68ea4d3ba1705da1e0d85da6684ac657912679a649e8868bd850d2c299cce13", - "sha256:e23d0cc5299223dcc37885dae624f382297717e459ea24053709675a976a3e19" + "sha256:57acc1d8533cbe51f6662a55434f0dbecfa2b9eaf115bede8f6fd00115a0c0d3", + "sha256:588c94b3d16b76cfed8e0be54932e5729cc185caffaa5a451e7ad2f7ed8b4043", + "sha256:68c8dd247f29f9a0d09375c9c6b8fdc64b60810ebf07ba4cdd64ceee3a58c7b7", + "sha256:70d9818f1c9cd5c48bb87804f2efc8692f1023dac7f1a1a5c61d454043c1d265", + "sha256:86a93cccd50f8c125286e637328ff4eef108400dd7089b46a7be3445eecfa391", + "sha256:a0f329125a926876f647c9fa0ef32801587a12328b4a3c741270464e3e4fa778", + "sha256:a3c252ab0fa1bb0d5a3f6449a4826732f3eb6c0270925548cac342bc9b22c225", + "sha256:b4bb4d3f5e232425e25dda21c070ce05168a786ac9eda43768ab7f3ac2770955", + "sha256:cd0618c5ba5bda5f4039b9398bb7fb6a317bb8298218c3de25c47c4740e4b95e", + "sha256:ceacb9e5f8474dcf45b940578591c7f3d960e82f926c707788a570b51ba59190", + "sha256:fe6a88094b64132c4bb3b631412e90032e8cfe9745a58370462240b8cb7553cd" ], "index": "pypi", - "version": "==5.1" + "version": "==5.1.1" }, "requests": { "hashes": [ - "sha256:502a824f31acdacb3a35b6690b5fbf0bc41d63a24a45c4004352b0242707598e", - "sha256:7bf2a778576d825600030a110f3c0e3e8edc51dfaafe1c146e39a2027784957b" + "sha256:11e007a8a2aa0323f5a921e9e6a2d7e4e67d9877e85773fba9ba6419025cbeb4", + "sha256:9cf5292fcd0f598c671cfc1e0d7d1a7f13bb8085e9a590f48c010551dc6c4b31" ], "index": "pypi", - "version": "==2.21.0" + "version": "==2.22.0" }, "safety": { "hashes": [ @@ -815,25 +881,32 @@ }, "urllib3": { "hashes": [ - "sha256:4c291ca23bbb55c76518905869ef34bdd5f0e46af7afe6861e8375643ffee1a0", - "sha256:9a247273df709c4fedb38c711e44292304f73f39ab01beda9f6b9fc375669ac3" + "sha256:2393a695cd12afedd0dcb26fe5d50d0cf248e5a66f75dbd89a3d4eb333a61af4", + "sha256:a637e5fae88995b256e3409dc4d52c2e2e0ba32c42a6365fee8bbd2238de3cfb" ], "index": "pypi", - "version": "==1.24.2" + "version": "==1.24.3" }, "virtualenv": { "hashes": [ - "sha256:15ee248d13e4001a691d9583948ad3947bcb8a289775102e4c4aa98a8b7a6d73", - "sha256:bfc98bb9b42a3029ee41b96dc00a34c2f254cbf7716bec824477b2c82741a5c4" + "sha256:b7335cddd9260a3dd214b73a2521ffc09647bde3e9457fcca31dc3be3999d04a", + "sha256:d28ca64c0f3f125f59cabf13e0a150e1c68e5eea60983cc4395d88c584495783" ], - "version": "==16.5.0" + "version": "==16.6.1" + }, + "wcwidth": { + "hashes": [ + "sha256:3df37372226d6e63e1b1e1eda15c594bca98a22d33a23832a90998faa96bc65e", + "sha256:f4ebe71925af7b40a864553f761ed559b43544f8f71746c2d756c7fe788ade7c" + ], + "version": "==0.1.7" }, "zipp": { "hashes": [ - "sha256:139391b239594fd8b91d856bc530fbd2df0892b17dd8d98a91f018715954185f", - "sha256:8047e4575ce8d700370a3301bbfc972896a5845eb62dd535da395b86be95dfad" + "sha256:8c1019c6aad13642199fbe458275ad6a84907634cc9f0989877ccc4a2840139d", + "sha256:ca943a7e809cc12257001ccfb99e3563da9af99d52f261725e96dfe0f9275bc3" ], - "version": "==0.4.0" + "version": "==0.5.1" } } } diff --git a/azure-pipelines.yml b/azure-pipelines.yml index a14364881..19df35c11 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -10,7 +10,7 @@ jobs: displayName: 'Lint & Test' pool: - vmImage: 'Ubuntu 16.04' + vmImage: ubuntu-16.04 variables: PIPENV_CACHE_DIR: ".cache/pipenv" @@ -18,10 +18,9 @@ jobs: PIP_SRC: ".cache/src" steps: - - script: sudo apt-get update - displayName: 'Updating package list' - - - script: sudo apt-get install build-essential curl docker libffi-dev libfreetype6-dev libxml2 libxml2-dev libxslt1-dev zlib1g zlib1g-dev + - script: | + sudo apt-get update + sudo apt-get install build-essential curl docker libffi-dev libfreetype6-dev libxml2 libxml2-dev libxslt1-dev zlib1g zlib1g-dev displayName: 'Install base dependencies' - task: UsePythonVersion@0 @@ -39,6 +38,9 @@ jobs: - script: python -m flake8 displayName: 'Run linter' + - script: BOT_TOKEN=foobar python -m pytest tests + displayName: Run tests + - job: build displayName: 'Build Containers' dependsOn: 'test' @@ -54,7 +56,5 @@ jobs: - task: ShellScript@2 displayName: 'Build and deploy containers' - inputs: scriptPath: scripts/deploy-azure.sh - args: '$(AUTODEPLOY_TOKEN) $(AUTODEPLOY_WEBHOOK)' diff --git a/bot/__init__.py b/bot/__init__.py index a088138a0..8efa5e53c 100644 --- a/bot/__init__.py +++ b/bot/__init__.py @@ -55,7 +55,7 @@ else: logging.basicConfig( - format="%(asctime)s pd.beardfist.com Bot: | %(name)30s | %(levelname)8s | %(message)s", + format="%(asctime)s pd.beardfist.com Bot: | %(name)33s | %(levelname)8s | %(message)s", datefmt="%b %d %H:%M:%S", level=logging.TRACE if DEBUG_MODE else logging.INFO, handlers=logging_handlers diff --git a/bot/__main__.py b/bot/__main__.py index f037a1475..e12508e6d 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -1,3 +1,4 @@ +import asyncio import logging import socket @@ -5,11 +6,11 @@ from aiohttp import AsyncResolver, ClientSession, TCPConnector from discord import Game from discord.ext.commands import Bot, when_mentioned_or +from bot.api import APIClient, APILoggingHandler from bot.constants import Bot as BotConfig, DEBUG_MODE -from bot.utils.service_discovery import wait_for_rmq -log = logging.getLogger(__name__) +log = logging.getLogger('bot') bot = Bot( command_prefix=when_mentioned_or(BotConfig.prefix), @@ -27,18 +28,11 @@ bot.http_session = ClientSession( family=socket.AF_INET, ) ) - -log.info("Waiting for RabbitMQ...") - -has_rmq = wait_for_rmq() - -if has_rmq: - log.info("RabbitMQ found") -else: - log.warning("Timed out while waiting for RabbitMQ") +bot.api_client = APIClient(loop=asyncio.get_event_loop()) +log.addHandler(APILoggingHandler(bot.api_client)) # Internal/debug -bot.load_extension("bot.cogs.events") +bot.load_extension("bot.cogs.error_handler") bot.load_extension("bot.cogs.filtering") bot.load_extension("bot.cogs.logging") bot.load_extension("bot.cogs.modlog") @@ -46,12 +40,10 @@ bot.load_extension("bot.cogs.security") # Commands, etc bot.load_extension("bot.cogs.antispam") -bot.load_extension("bot.cogs.bigbrother") bot.load_extension("bot.cogs.bot") bot.load_extension("bot.cogs.clean") bot.load_extension("bot.cogs.cogs") bot.load_extension("bot.cogs.help") -bot.load_extension("bot.cogs.rules") # Only load this in production if not DEBUG_MODE: @@ -73,14 +65,13 @@ bot.load_extension("bot.cogs.reminders") bot.load_extension("bot.cogs.site") bot.load_extension("bot.cogs.snekbox") bot.load_extension("bot.cogs.superstarify") +bot.load_extension("bot.cogs.sync") bot.load_extension("bot.cogs.tags") bot.load_extension("bot.cogs.token_remover") bot.load_extension("bot.cogs.utils") +bot.load_extension("bot.cogs.watchchannels") bot.load_extension("bot.cogs.wolfram") -if has_rmq: - bot.load_extension("bot.cogs.rmq") - bot.run(BotConfig.token) bot.http_session.close() # Close the aiohttp session when the bot finishes running diff --git a/bot/api.py b/bot/api.py new file mode 100644 index 000000000..6ac7ddb95 --- /dev/null +++ b/bot/api.py @@ -0,0 +1,137 @@ +import asyncio +import logging +from urllib.parse import quote as quote_url + +import aiohttp + +from .constants import Keys, URLs + +log = logging.getLogger(__name__) + + +class ResponseCodeError(ValueError): + def __init__(self, response: aiohttp.ClientResponse): + self.response = response + + +class APIClient: + def __init__(self, **kwargs): + auth_headers = { + 'Authorization': f"Token {Keys.site_api}" + } + + if 'headers' in kwargs: + kwargs['headers'].update(auth_headers) + else: + kwargs['headers'] = auth_headers + + self.session = aiohttp.ClientSession(**kwargs) + + @staticmethod + def _url_for(endpoint: str): + return f"{URLs.site_schema}{URLs.site_api}/{quote_url(endpoint)}" + + def maybe_raise_for_status(self, response: aiohttp.ClientResponse, should_raise: bool): + if should_raise and response.status >= 400: + raise ResponseCodeError(response=response) + + async def get(self, endpoint: str, *args, raise_for_status: bool = True, **kwargs): + async with self.session.get(self._url_for(endpoint), *args, **kwargs) as resp: + self.maybe_raise_for_status(resp, raise_for_status) + return await resp.json() + + async def patch(self, endpoint: str, *args, raise_for_status: bool = True, **kwargs): + async with self.session.patch(self._url_for(endpoint), *args, **kwargs) as resp: + self.maybe_raise_for_status(resp, raise_for_status) + return await resp.json() + + async def post(self, endpoint: str, *args, raise_for_status: bool = True, **kwargs): + async with self.session.post(self._url_for(endpoint), *args, **kwargs) as resp: + self.maybe_raise_for_status(resp, raise_for_status) + return await resp.json() + + async def put(self, endpoint: str, *args, raise_for_status: bool = True, **kwargs): + async with self.session.put(self._url_for(endpoint), *args, **kwargs) as resp: + self.maybe_raise_for_status(resp, raise_for_status) + return await resp.json() + + async def delete(self, endpoint: str, *args, raise_for_status: bool = True, **kwargs): + async with self.session.delete(self._url_for(endpoint), *args, **kwargs) as resp: + if resp.status == 204: + return None + + self.maybe_raise_for_status(resp, raise_for_status) + return await resp.json() + + +def loop_is_running() -> bool: + # asyncio does not have a way to say "call this when the event + # loop is running", see e.g. `callWhenRunning` from twisted. + + try: + asyncio.get_running_loop() + except RuntimeError: + return False + return True + + +class APILoggingHandler(logging.StreamHandler): + 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): + 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): + # Ignore logging messages which are sent by this logging handler + # itself. This is required because if we were to not ignore + # messages emitted by this handler, we would infinitely recurse + # back down into this logging handler, making the reactor run + # like crazy, and eventually OOM something. Let's not do that... + if 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): + for task in self.queue: + asyncio.create_task(task) + + if self.queue: + log.debug( + "Scheduled %d pending logging tasks.", + len(self.queue), + extra={'via_handler': True} + ) + + self.queue.clear() diff --git a/bot/cogs/alias.py b/bot/cogs/alias.py index bf40fe409..85d101448 100644 --- a/bot/cogs/alias.py +++ b/bot/cogs/alias.py @@ -1,11 +1,13 @@ import inspect import logging +from typing import Union -from discord import Colour, Embed, User +from discord import Colour, Embed, Member, User from discord.ext.commands import ( Command, Context, clean_content, command, group ) +from bot.cogs.watchchannels.watchchannel import proxy_user from bot.converters import TagNameConverter from bot.pagination import LinePaginator @@ -70,9 +72,7 @@ class Alias: await self.invoke(ctx, "site resources") @command(name="watch", hidden=True) - async def bigbrother_watch_alias( - self, ctx: Context, user: User, *, reason: str - ): + async def bigbrother_watch_alias(self, ctx, user: Union[Member, User, proxy_user], *, reason: str): """ Alias for invoking <prefix>bigbrother watch [user] [reason]. """ @@ -80,7 +80,7 @@ class Alias: await self.invoke(ctx, "bigbrother watch", user, reason=reason) @command(name="unwatch", hidden=True) - async def bigbrother_unwatch_alias(self, ctx, user: User, *, reason: str): + async def bigbrother_unwatch_alias(self, ctx, user: Union[User, proxy_user], *, reason: str): """ Alias for invoking <prefix>bigbrother unwatch [user] [reason]. """ @@ -103,6 +103,14 @@ class Alias: await self.invoke(ctx, "site faq") + @command(name="rules", hidden=True) + async def site_rules_alias(self, ctx): + """ + Alias for invoking <prefix>site rules. + """ + + await self.invoke(ctx, "site rules") + @command(name="reload", hidden=True) async def cogs_reload_alias(self, ctx, *, cog_name: str): """ @@ -173,6 +181,22 @@ class Alias: await self.invoke(ctx, "docs get", symbol) + @command(name="nominate", hidden=True) + async def nomination_add_alias(self, ctx, user: Union[Member, User, proxy_user], *, reason: str): + """ + Alias for invoking <prefix>talentpool add [user] [reason]. + """ + + await self.invoke(ctx, "talentpool add", user, reason=reason) + + @command(name="unnominate", hidden=True) + async def nomination_end_alias(self, ctx, user: Union[User, proxy_user], *, reason: str): + """ + Alias for invoking <prefix>nomination end [user] [reason]. + """ + + await self.invoke(ctx, "nomination end", user, reason=reason) + def setup(bot): bot.add_cog(Alias(bot)) diff --git a/bot/cogs/antispam.py b/bot/cogs/antispam.py index 03551e806..0c6a02bf9 100644 --- a/bot/cogs/antispam.py +++ b/bot/cogs/antispam.py @@ -110,7 +110,7 @@ class AntiSpam: # For multiple messages or those with excessive newlines, use the logs API if len(messages) > 1 or rule_name == 'newlines': - url = await self.mod_log.upload_log(messages) + url = await self.mod_log.upload_log(messages, msg.guild.me.id) mod_alert_message += f"A complete log of the offending messages can be found [here]({url})" else: mod_alert_message += "Message:\n" diff --git a/bot/cogs/bigbrother.py b/bot/cogs/bigbrother.py deleted file mode 100644 index 97655812b..000000000 --- a/bot/cogs/bigbrother.py +++ /dev/null @@ -1,501 +0,0 @@ -import asyncio -import logging -import re -from collections import defaultdict, deque -from time import strptime, struct_time -from typing import List, NamedTuple, Optional, Union - -from aiohttp import ClientError -from discord import Color, Embed, Guild, Member, Message, TextChannel, User, errors -from discord.ext.commands import Bot, Context, command, group - -from bot.constants import ( - BigBrother as BigBrotherConfig, Channels, Emojis, - Guild as GuildConfig, Keys, - MODERATION_ROLES, STAFF_ROLES, URLs -) -from bot.decorators import with_role -from bot.pagination import LinePaginator -from bot.utils import messages -from bot.utils.moderation import post_infraction -from bot.utils.time import parse_rfc1123, time_since - -log = logging.getLogger(__name__) - -URL_RE = re.compile(r"(https?://[^\s]+)") - - -class WatchInformation(NamedTuple): - reason: str - actor_id: Optional[int] - inserted_at: Optional[str] - - -class BigBrother: - """User monitoring to assist with moderation.""" - - HEADERS = {'X-API-Key': Keys.site_api} - - def __init__(self, bot: Bot): - self.bot = bot - self.watched_users = {} # { user_id: log_channel_id } - self.watch_reasons = {} # { user_id: watch_reason } - self.channel_queues = defaultdict(lambda: defaultdict(deque)) # { user_id: { channel_id: queue(messages) } - self.last_log = [None, None, 0] # [user_id, channel_id, message_count] - self.consuming = False - self.consume_task = None - self.infraction_watch_prefix = "bb watch: " # Please do not change or we won't be able to find old reasons - self.nomination_prefix = "Helper nomination: " - - self.bot.loop.create_task(self.get_watched_users()) - - def update_cache(self, api_response: List[dict]): - """ - Updates the internal cache of watched users from the given `api_response`. - This function will only add (or update) existing keys, it will not delete - keys that were not present in the API response. - A user is only added if the bot can find a channel - with the given `channel_id` in its channel cache. - """ - - for entry in api_response: - user_id = int(entry['user_id']) - channel_id = int(entry['channel_id']) - channel = self.bot.get_channel(channel_id) - - if channel is not None: - self.watched_users[user_id] = channel - else: - log.error( - f"Site specified to relay messages by `{user_id}` in `{channel_id}`, " - "but the given channel could not be found. Ignoring." - ) - - async def get_watched_users(self): - """Retrieves watched users from the API.""" - - await self.bot.wait_until_ready() - async with self.bot.http_session.get(URLs.site_bigbrother_api, headers=self.HEADERS) as response: - data = await response.json() - self.update_cache(data) - - async def update_watched_users(self): - async with self.bot.http_session.get(URLs.site_bigbrother_api, headers=self.HEADERS) as response: - if response.status == 200: - data = await response.json() - self.update_cache(data) - log.trace("Updated Big Brother watchlist cache") - return True - else: - return False - - async def get_watch_information(self, user_id: int, prefix: str) -> WatchInformation: - """ Fetches and returns the latest watch reason for a user using the infraction API """ - - re_bb_watch = rf"^{prefix}" - user_id = str(user_id) - - try: - response = await self.bot.http_session.get( - URLs.site_infractions_user_type.format( - user_id=user_id, - infraction_type="note", - ), - params={"search": re_bb_watch, "hidden": "True", "active": "False"}, - headers=self.HEADERS - ) - infraction_list = await response.json() - except ClientError: - log.exception(f"Failed to retrieve bb watch reason for {user_id}.") - return WatchInformation(reason="(error retrieving bb reason)", actor_id=None, inserted_at=None) - - if infraction_list: - # Get the latest watch reason - latest_reason_infraction = max(infraction_list, key=self._parse_infraction_time) - - # Get the actor of the watch/nominate action - actor_id = int(latest_reason_infraction["actor"]["user_id"]) - - # Get the date the watch was set - date = latest_reason_infraction["inserted_at"] - - # Get the latest reason without the prefix - latest_reason = latest_reason_infraction['reason'][len(prefix):] - - log.trace(f"The latest bb watch reason for {user_id}: {latest_reason}") - return WatchInformation(reason=latest_reason, actor_id=actor_id, inserted_at=date) - - log.trace(f"No bb watch reason found for {user_id}; returning defaults") - return WatchInformation(reason="(no reason specified)", actor_id=None, inserted_at=None) - - @staticmethod - def _parse_infraction_time(infraction: dict) -> struct_time: - """ - Helper function that retrieves the insertion time from the infraction dictionary, - converts the retrieved RFC1123 date_time string to a time object, and returns it - so infractions can be sorted by their insertion time. - """ - - date_string = infraction["inserted_at"] - return strptime(date_string, "%a, %d %b %Y %H:%M:%S %Z") - - async def on_member_ban(self, guild: Guild, user: Union[User, Member]): - if guild.id == GuildConfig.id and user.id in self.watched_users: - url = f"{URLs.site_bigbrother_api}?user_id={user.id}" - channel = self.watched_users[user.id] - - async with self.bot.http_session.delete(url, headers=self.HEADERS) as response: - del self.watched_users[user.id] - del self.channel_queues[user.id] - del self.watch_reasons[user.id] - if response.status == 204: - await channel.send( - f"{Emojis.bb_message}:hammer: {user} got banned, so " - f"`BigBrother` will no longer relay their messages to {channel}" - ) - - else: - data = await response.json() - reason = data.get('error_message', "no message provided") - await channel.send( - f"{Emojis.bb_message}:x: {user} got banned, but trying to remove them from" - f"BigBrother's user dictionary on the API returned an error: {reason}" - ) - - async def on_message(self, msg: Message): - """Queues up messages sent by watched users.""" - - if msg.author.id in self.watched_users: - if not self.consuming: - self.consume_task = self.bot.loop.create_task(self.consume_messages()) - - if self.consuming and self.consume_task.done(): - # This should never happen, so something went wrong - - log.error("The consume_task has finished, but did not reset the self.consuming boolean") - e = self.consume_task.exception() - if e: - log.exception("The Exception for the Task:", exc_info=e) - else: - log.error("However, an Exception was not found.") - - self.consume_task = self.bot.loop.create_task(self.consume_messages()) - - log.trace(f"Received message: {msg.content} ({len(msg.attachments)} attachments)") - self.channel_queues[msg.author.id][msg.channel.id].append(msg) - - async def consume_messages(self): - """Consumes the message queues to log watched users' messages.""" - - if not self.consuming: - self.consuming = True - log.trace("Sleeping before consuming...") - await asyncio.sleep(BigBrotherConfig.log_delay) - - log.trace("Begin consuming messages.") - channel_queues = self.channel_queues.copy() - self.channel_queues.clear() - for user_id, queues in channel_queues.items(): - for _, queue in queues.items(): - channel = self.watched_users[user_id] - while queue: - msg = queue.popleft() - log.trace(f"Consuming message: {msg.clean_content} ({len(msg.attachments)} attachments)") - - self.last_log[2] += 1 # Increment message count. - await self.send_header(msg, channel) - await self.log_message(msg, channel) - - if self.channel_queues: - log.trace("Queue not empty; continue consumption.") - self.consume_task = self.bot.loop.create_task(self.consume_messages()) - else: - log.trace("Done consuming messages.") - self.consuming = False - - async def send_header(self, message: Message, destination: TextChannel): - """ - Sends a log message header to the given channel. - - A header is only sent if the user or channel are different than the previous, or if the configured message - limit for a single header has been exceeded. - - :param message: the first message in the queue - :param destination: the channel in which to send the header - """ - - last_user, last_channel, msg_count = self.last_log - limit = BigBrotherConfig.header_message_limit - - # Send header if user/channel are different or if message limit exceeded. - if message.author.id != last_user or message.channel.id != last_channel or msg_count > limit: - # Retrieve watch reason from API if it's not already in the cache - if message.author.id not in self.watch_reasons: - log.trace(f"No watch information for {message.author.id} found in cache; retrieving from API") - if destination == self.bot.get_channel(Channels.talent_pool): - prefix = self.nomination_prefix - else: - prefix = self.infraction_watch_prefix - user_watch_information = await self.get_watch_information(message.author.id, prefix) - self.watch_reasons[message.author.id] = user_watch_information - - self.last_log = [message.author.id, message.channel.id, 0] - - # Get reason, actor, inserted_at - reason, actor_id, inserted_at = self.watch_reasons[message.author.id] - - # Setting up the default author_field - author_field = message.author.nick or message.author.name - - # When we're dealing with a talent-pool header, add nomination info to the author field - if destination == self.bot.get_channel(Channels.talent_pool): - log.trace("We're sending a header to the talent-pool; let's add nomination info") - # If a reason was provided, both should be known - if actor_id and inserted_at: - # Parse actor name - guild: GuildConfig = self.bot.get_guild(GuildConfig.id) - actor_as_member = guild.get_member(actor_id) - actor = actor_as_member.nick or actor_as_member.name - - # Get time delta since insertion - date_time = parse_rfc1123(inserted_at).replace(tzinfo=None) - time_delta = time_since(date_time, precision="minutes", max_units=1) - - # Adding nomination info to author_field - author_field = f"{author_field} (nominated {time_delta} by {actor})" - else: - if inserted_at: - # Get time delta since insertion - date_time = parse_rfc1123(inserted_at).replace(tzinfo=None) - time_delta = time_since(date_time, precision="minutes", max_units=1) - - author_field = f"{author_field} (added {time_delta})" - - embed = Embed(description=f"{message.author.mention} in [#{message.channel.name}]({message.jump_url})") - embed.set_author(name=author_field, icon_url=message.author.avatar_url) - embed.set_footer(text=f"Reason: {reason}") - await destination.send(embed=embed) - - @staticmethod - async def log_message(message: Message, destination: TextChannel): - """ - Logs a watched user's message in the given channel. - - Attachments are also sent. All non-image or non-video URLs are put in inline code blocks to prevent preview - embeds from being automatically generated. - - :param message: the message to log - :param destination: the channel in which to log the message - """ - - content = message.clean_content - if content: - # Put all non-media URLs in inline code blocks. - media_urls = {embed.url for embed in message.embeds if embed.type in ("image", "video")} - for url in URL_RE.findall(content): - if url not in media_urls: - content = content.replace(url, f"`{url}`") - - await destination.send(content) - - try: - await messages.send_attachments(message, destination) - except (errors.Forbidden, errors.NotFound): - e = Embed( - description=":x: **This message contained an attachment, but it could not be retrieved**", - color=Color.red() - ) - await destination.send(embed=e) - - async def _watch_user(self, ctx: Context, user: User, reason: str, channel_id: int): - post_data = { - 'user_id': str(user.id), - 'channel_id': str(channel_id) - } - - async with self.bot.http_session.post( - URLs.site_bigbrother_api, - headers=self.HEADERS, - json=post_data - ) as response: - if response.status == 204: - if channel_id == Channels.talent_pool: - await ctx.send(f":ok_hand: added {user} to the <#{channel_id}>!") - else: - await ctx.send(f":ok_hand: will now relay messages sent by {user} in <#{channel_id}>") - - channel = self.bot.get_channel(channel_id) - if channel is None: - log.error( - f"could not update internal cache, failed to find a channel with ID {channel_id}" - ) - else: - self.watched_users[user.id] = channel - - # Add a note (shadow warning) with the reason for watching - await post_infraction(ctx, user, type="warning", reason=reason, hidden=True) - else: - data = await response.json() - error_reason = data.get('error_message', "no message provided") - await ctx.send(f":x: the API returned an error: {error_reason}") - - @group(name='bigbrother', aliases=('bb',), invoke_without_command=True) - @with_role(*MODERATION_ROLES) - async def bigbrother_group(self, ctx: Context): - """Monitor users, NSA-style.""" - - await ctx.invoke(self.bot.get_command("help"), "bigbrother") - - @bigbrother_group.command(name='watched', aliases=('all',)) - @with_role(*MODERATION_ROLES) - async def watched_command(self, ctx: Context, from_cache: bool = True): - """ - Shows all users that are currently monitored and in which channel. - By default, the users are returned from the cache. - If this is not desired, `from_cache` can be given as a falsy value, e.g. e.g. 'no'. - """ - if not from_cache: - updated = await self.update_watched_users() - if not updated: - await ctx.send(f":x: Failed to update cache: non-200 response from the API") - return - title = "Watched users (updated cache)" - else: - title = "Watched users (from cache)" - - lines = tuple( - f"• <@{user_id}> in <#{self.watched_users[user_id].id}>" - for user_id in self.watched_users - ) - await LinePaginator.paginate( - lines or ("There's nothing here yet.",), - ctx, - Embed(title=title, color=Color.blue()), - empty=False - ) - - @bigbrother_group.command(name='watch', aliases=('w',)) - @with_role(*MODERATION_ROLES) - async def watch_command(self, ctx: Context, user: User, *, reason: str): - """ - Relay messages sent by the given `user` to the `#big-brother-logs` channel - - A `reason` for watching is required, which is added for the user to be watched as a - note (aka: shadow warning) - """ - - # Update cache to avoid double watching of a user - await self.update_watched_users() - - if user.id in self.watched_users: - message = f":x: User is already being watched in {self.watched_users[user.id].name}" - await ctx.send(message) - return - - channel_id = Channels.big_brother_logs - - reason = f"{self.infraction_watch_prefix}{reason}" - - await self._watch_user(ctx, user, reason, channel_id) - - @bigbrother_group.command(name='unwatch', aliases=('uw',)) - @with_role(*MODERATION_ROLES) - async def unwatch_command(self, ctx: Context, user: User, *, reason: str): - """ - Stop relaying messages by the given `user`. - - A `reason` for unwatching is required, which will be added as a note to the user. - """ - - url = f"{URLs.site_bigbrother_api}?user_id={user.id}" - async with self.bot.http_session.delete(url, headers=self.HEADERS) as response: - if response.status == 204: - await ctx.send(f":ok_hand: will no longer relay messages sent by {user}") - - if user.id in self.watched_users: - channel = self.watched_users[user.id] - - del self.watched_users[user.id] - if user.id in self.channel_queues: - del self.channel_queues[user.id] - if user.id in self.watch_reasons: - del self.watch_reasons[user.id] - else: - channel = None - log.warning(f"user {user.id} was unwatched but was not found in the cache") - - reason = f"Unwatched ({channel.name if channel else 'unknown channel'}): {reason}" - await post_infraction(ctx, user, type="warning", reason=reason, hidden=True) - - else: - data = await response.json() - reason = data.get('error_message', "no message provided") - await ctx.send(f":x: the API returned an error: {reason}") - - @bigbrother_group.command(name='nominate', aliases=('n',)) - @with_role(*MODERATION_ROLES) - async def nominate_command(self, ctx: Context, user: User, *, reason: str): - """ - Nominates a user for the helper role by adding them to the talent-pool channel - - A `reason` for the nomination is required and will be added as a note to - the user's records. - """ - - # Note: This function is called from HelperNomination.nominate_command so that the - # !nominate command does not show up under "BigBrother" in the help embed, but under - # the header HelperNomination for users with the helper role. - - member = ctx.guild.get_member(user.id) - - if member and any(role.id in STAFF_ROLES for role in member.roles): - await ctx.send(f":x: {user.mention} is already a staff member!") - return - - channel_id = Channels.talent_pool - - # Update watch cache to avoid overwriting active nomination reason - await self.update_watched_users() - - if user.id in self.watched_users: - if self.watched_users[user.id].id == Channels.talent_pool: - prefix = "Additional nomination: " - else: - # If the user is being watched in big-brother, don't add them to talent-pool - message = ( - f":x: {user.mention} can't be added to the talent-pool " - "as they are currently being watched in big-brother." - ) - await ctx.send(message) - return - else: - prefix = self.nomination_prefix - - reason = f"{prefix}{reason}" - - await self._watch_user(ctx, user, reason, channel_id) - - -class HelperNomination: - def __init__(self, bot): - self.bot = bot - - @command(name='nominate', aliases=('n',)) - @with_role(*STAFF_ROLES) - async def nominate_command(self, ctx: Context, user: User, *, reason: str): - """ - Nominates a user for the helper role by adding them to the talent-pool channel - - A `reason` for the nomination is required and will be added as a note to - the user's records. - """ - - cmd = self.bot.get_command("bigbrother nominate") - - await ctx.invoke(cmd, user, reason=reason) - - -def setup(bot: Bot): - bot.add_cog(BigBrother(bot)) - bot.add_cog(HelperNomination(bot)) - log.info("Cog loaded: BigBrother") diff --git a/bot/cogs/clean.py b/bot/cogs/clean.py index b34d1118b..e7b6bac85 100644 --- a/bot/cogs/clean.py +++ b/bot/cogs/clean.py @@ -165,7 +165,7 @@ class Clean: # Reverse the list to restore chronological order if messages: messages = list(reversed(messages)) - log_url = await self.mod_log.upload_log(messages) + log_url = await self.mod_log.upload_log(messages, ctx.author.id) else: # Can't build an embed, nothing to clean! embed = Embed( diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py index f07d9df9f..c67fa2807 100644 --- a/bot/cogs/defcon.py +++ b/bot/cogs/defcon.py @@ -5,7 +5,7 @@ from discord import Colour, Embed, Member from discord.ext.commands import Bot, Context, group from bot.cogs.modlog import ModLog -from bot.constants import Channels, Colours, Emojis, Event, Icons, Keys, Roles, URLs +from bot.constants import Channels, Colours, Emojis, Event, Icons, Keys, Roles from bot.decorators import with_role log = logging.getLogger(__name__) @@ -40,13 +40,8 @@ class Defcon: async def on_ready(self): try: - response = await self.bot.http_session.get( - URLs.site_settings_api, - headers=self.headers, - params={"keys": "defcon_enabled,defcon_days"} - ) - - data = await response.json() + response = await self.bot.api_client.get('bot/bot-settings/defcon') + data = response['data'] except Exception: # Yikes! log.exception("Unable to get DEFCON settings!") @@ -55,9 +50,9 @@ class Defcon: ) else: - if data["defcon_enabled"]: + if data["enabled"]: self.enabled = True - self.days = timedelta(days=data["defcon_days"]) + self.days = timedelta(days=data["days"]) log.warning(f"DEFCON enabled: {self.days.days} days") else: @@ -118,13 +113,18 @@ class Defcon: self.enabled = True try: - response = await self.bot.http_session.put( - URLs.site_settings_api, - headers=self.headers, - json={"defcon_enabled": True} + await self.bot.api_client.put( + 'bot/bot-settings/defcon', + json={ + 'name': 'defcon', + 'data': { + 'enabled': True, + # TODO: retrieve old days count + 'days': 0 + } + } ) - await response.json() except Exception as e: log.exception("Unable to update DEFCON settings.") await ctx.send( @@ -142,6 +142,7 @@ class Defcon: "restarted.\n\n" f"```py\n{e}\n```" ) + else: await ctx.send(f"{Emojis.defcon_enabled} DEFCON enabled.") @@ -163,13 +164,16 @@ class Defcon: self.enabled = False try: - response = await self.bot.http_session.put( - URLs.site_settings_api, - headers=self.headers, - json={"defcon_enabled": False} + await self.bot.api_client.put( + 'bot/bot-settings/defcon', + json={ + 'data': { + 'days': 0, + 'enabled': False + }, + 'name': 'defcon' + } ) - - await response.json() except Exception as e: log.exception("Unable to update DEFCON settings.") await ctx.send( @@ -221,13 +225,16 @@ class Defcon: self.days = timedelta(days=days) try: - response = await self.bot.http_session.put( - URLs.site_settings_api, - headers=self.headers, - json={"defcon_days": days} + await self.bot.api_client.put( + 'bot/bot-settings/defcon', + json={ + 'data': { + 'days': days, + 'enabled': True + }, + 'name': 'defcon' + } ) - - await response.json() except Exception as e: log.exception("Unable to update DEFCON settings.") await ctx.send( diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index 2f2cf8000..aa49b0c25 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -1,11 +1,10 @@ import asyncio import functools import logging -import random import re import textwrap from collections import OrderedDict -from typing import Dict, List, Optional, Tuple +from typing import Optional, Tuple import discord from bs4 import BeautifulSoup @@ -14,7 +13,7 @@ from markdownify import MarkdownConverter from requests import ConnectionError from sphinx.ext import intersphinx -from bot.constants import ERROR_REPLIES, Keys, MODERATION_ROLES, URLs +from bot.constants import MODERATION_ROLES from bot.converters import ValidPythonIdentifier, ValidURL from bot.decorators import with_role from bot.pagination import LinePaginator @@ -126,7 +125,6 @@ class Doc: self.base_urls = {} self.bot = bot self.inventories = {} - self.headers = {"X-API-KEY": Keys.site_api} async def on_ready(self): await self.refresh_inventory() @@ -179,7 +177,7 @@ class Doc: coros = [ self.update_single( package["package"], package["base_url"], package["inventory_url"], config - ) for package in await self.get_all_packages() + ) for package in await self.bot.api_client.get('bot/documentation-links') ] await asyncio.gather(*coros) @@ -267,95 +265,6 @@ class Doc: description=f"```py\n{signature}```{description}" ) - async def get_all_packages(self) -> List[Dict[str, str]]: - """ - Performs HTTP GET to get all packages from the website. - - :return: - A list of packages, in the following format: - [ - { - "package": "example-package", - "base_url": "https://example.readthedocs.io", - "inventory_url": "https://example.readthedocs.io/objects.inv" - }, - ... - ] - `package` specifies the package name, for example 'aiohttp'. - `base_url` specifies the documentation root URL, used to build absolute links. - `inventory_url` specifies the location of the Intersphinx inventory. - """ - - async with self.bot.http_session.get(URLs.site_docs_api, headers=self.headers) as resp: - return await resp.json() - - async def get_package(self, package_name: str) -> Optional[Dict[str, str]]: - """ - Performs HTTP GET to get the specified package from the documentation database. - - :param package_name: The package name for which information should be returned. - :return: - Either a dictionary with information in the following format: - { - "package": "example-package", - "base_url": "https://example.readthedocs.io", - "inventory_url": "https://example.readthedocs.io/objects.inv" - } - or `None` if the site didn't returned no results for the given name. - """ - - params = {"package": package_name} - - async with self.bot.http_session.get(URLs.site_docs_api, - headers=self.headers, - params=params) as resp: - package_data = await resp.json() - if not package_data: - return None - return package_data[0] - - async def set_package(self, name: str, base_url: str, inventory_url: str) -> Dict[str, bool]: - """ - Performs HTTP POST to add a new package to the website's documentation database. - - :param name: The name of the package, for example `aiohttp`. - :param base_url: The documentation root URL, used to build absolute links. - :param inventory_url: The absolute URl to the intersphinx inventory of the package. - - :return: The JSON response of the server, which is always: - { - "success": True - } - """ - - package_json = { - 'package': name, - 'base_url': base_url, - 'inventory_url': inventory_url - } - - async with self.bot.http_session.post(URLs.site_docs_api, - headers=self.headers, - json=package_json) as resp: - return await resp.json() - - async def delete_package(self, name: str) -> bool: - """ - Performs HTTP DELETE to delete the specified package from the documentation database. - - :param name: The package to delete. - - :return: `True` if successful, `False` if the package is unknown. - """ - - package_json = {'package': name} - - async with self.bot.http_session.delete(URLs.site_docs_api, - headers=self.headers, - json=package_json) as resp: - changes = await resp.json() - return changes["deleted"] == 1 # Did the package delete successfully? - @commands.group(name='docs', aliases=('doc', 'd'), invoke_without_command=True) async def docs_group(self, ctx, symbol: commands.clean_content = None): """Lookup documentation for Python symbols.""" @@ -386,7 +295,12 @@ class Doc: ) lines = sorted(f"• [`{name}`]({url})" for name, url in self.base_urls.items()) - await LinePaginator.paginate(lines, ctx, inventory_embed, max_size=400, empty=False) + if self.base_urls: + await LinePaginator.paginate(lines, ctx, inventory_embed, max_size=400, empty=False) + + else: + inventory_embed.description = "Hmmm, seems like there's nothing here yet." + await ctx.send(embed=inventory_embed) else: # Fetching documentation for a symbol (at least for the first time, since @@ -427,7 +341,13 @@ class Doc: https://discordpy.readthedocs.io/en/rewrite/objects.inv """ - await self.set_package(package_name, base_url, inventory_url) + body = { + 'package': package_name, + 'base_url': base_url, + 'inventory_url': inventory_url + } + await self.bot.api_client.post('bot/documentation-links', json=body) + log.info( f"User @{ctx.author.name}#{ctx.author.discriminator} ({ctx.author.id}) " "added a new documentation package:\n" @@ -455,42 +375,13 @@ class Doc: !docs delete aiohttp """ - success = await self.delete_package(package_name) - if success: + await self.bot.api_client.delete(f'bot/documentation-links/{package_name}') - async with ctx.typing(): - # Rebuild the inventory to ensure that everything - # that was from this package is properly deleted. - await self.refresh_inventory() - await ctx.send(f"Successfully deleted `{package_name}` and refreshed inventory.") - - else: - await ctx.send( - f"Can't find any package named `{package_name}` in the database. " - "View all known packages by using `docs.get()`." - ) - - @get_command.error - @delete_command.error - @set_command.error - async def general_command_error(self, ctx, error: commands.CommandError): - """ - Handle the `BadArgument` error caused by - the commands when argument validation fails. - - :param ctx: Discord message context of the message creating the error - :param error: The error raised, usually `BadArgument` - """ - - if isinstance(error, commands.BadArgument): - embed = discord.Embed( - title=random.choice(ERROR_REPLIES), - description=f"Error: {error}", - colour=discord.Colour.red() - ) - await ctx.send(embed=embed) - else: - log.exception(f"Unhandled error: {error}") + async with ctx.typing(): + # Rebuild the inventory to ensure that everything + # that was from this package is properly deleted. + await self.refresh_inventory() + await ctx.send(f"Successfully deleted `{package_name}` and refreshed inventory.") def setup(bot): diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py new file mode 100644 index 000000000..2063df09d --- /dev/null +++ b/bot/cogs/error_handler.py @@ -0,0 +1,92 @@ +import contextlib +import logging + +from discord.ext.commands import ( + BadArgument, + BotMissingPermissions, + CommandError, + CommandInvokeError, + CommandNotFound, + NoPrivateMessage, + UserInputError, +) +from discord.ext.commands import Bot, Context + +from bot.api import ResponseCodeError + + +log = logging.getLogger(__name__) + + +class ErrorHandler: + """Handles errors emitted from commands.""" + + def __init__(self, bot: Bot): + self.bot = bot + + async def on_command_error(self, ctx: Context, e: CommandError): + command = ctx.command + parent = None + + if command is not None: + parent = command.parent + + if parent and command: + help_command = (self.bot.get_command("help"), parent.name, command.name) + elif command: + help_command = (self.bot.get_command("help"), command.name) + else: + help_command = (self.bot.get_command("help"),) + + if hasattr(command, "on_error"): + log.debug(f"Command {command} has a local error handler, ignoring.") + return + + if isinstance(e, CommandNotFound) and not hasattr(ctx, "invoked_from_error_handler"): + tags_get_command = self.bot.get_command("tags get") + ctx.invoked_from_error_handler = True + + # Return to not raise the exception + with contextlib.suppress(ResponseCodeError): + return await ctx.invoke(tags_get_command, tag_name=ctx.invoked_with) + elif isinstance(e, BadArgument): + await ctx.send(f"Bad argument: {e}\n") + await ctx.invoke(*help_command) + elif isinstance(e, UserInputError): + await ctx.send("Something about your input seems off. Check the arguments:") + await ctx.invoke(*help_command) + elif isinstance(e, NoPrivateMessage): + await ctx.send("Sorry, this command can't be used in a private message!") + elif isinstance(e, BotMissingPermissions): + await ctx.send( + f"Sorry, it looks like I don't have the permissions I need to do that.\n\n" + f"Here's what I'm missing: **{e.missing_perms}**" + ) + elif isinstance(e, CommandInvokeError): + if isinstance(e.original, ResponseCodeError): + if e.original.response.status == 404: + await ctx.send("There does not seem to be anything matching your query.") + elif e.original.response.status == 400: + content = await e.original.response.json() + log.debug("API gave bad request on command. Response: %r.", content) + await ctx.send("According to the API, your request is malformed.") + elif 500 <= e.original.response.status < 600: + await ctx.send("Sorry, there seems to be an internal issue with the API.") + else: + await ctx.send( + "Got an unexpected status code from the " + f"API (`{e.original.response.code}`)." + ) + + else: + await ctx.send( + f"Sorry, an unexpected error occurred. Please let us know!\n\n```{e}```" + ) + raise e.original + else: + raise e + + +def setup(bot: Bot): + bot.add_cog(ErrorHandler(bot)) + log.info("Cog loaded: Events") diff --git a/bot/cogs/events.py b/bot/cogs/events.py deleted file mode 100644 index 2819b7dcc..000000000 --- a/bot/cogs/events.py +++ /dev/null @@ -1,311 +0,0 @@ -import logging -from functools import partial -from typing import List - -from discord import Colour, Embed, Member, Object -from discord.ext.commands import ( - BadArgument, Bot, BotMissingPermissions, - CommandError, CommandInvokeError, CommandNotFound, - Context, NoPrivateMessage, UserInputError -) - -from bot.constants import ( - Channels, Colours, DEBUG_MODE, - Guild, Icons, Keys, - Roles, URLs -) -from bot.utils import chunks - -log = logging.getLogger(__name__) - -RESTORE_ROLES = (str(Roles.muted), str(Roles.announcements)) - - -class Events: - """No commands, just event handlers.""" - - def __init__(self, bot: Bot): - self.bot = bot - self.headers = {"X-API-KEY": Keys.site_api} - - @property - def send_log(self) -> partial: - cog = self.bot.get_cog("ModLog") - return partial(cog.send_log_message, channel_id=Channels.userlog) - - async def send_updated_users(self, *users, replace_all=False): - users = list(filter(lambda user: str(Roles.verified) in user["roles"], users)) - - for chunk in chunks(users, 1000): - response = None - - try: - if replace_all: - response = await self.bot.http_session.post( - url=URLs.site_user_api, - json=chunk, - headers={"X-API-Key": Keys.site_api} - ) - else: - response = await self.bot.http_session.put( - url=URLs.site_user_api, - json=chunk, - headers={"X-API-Key": Keys.site_api} - ) - - await response.json() # We do this to ensure we got a proper response from the site - except Exception: - if not response: - log.exception(f"Failed to send {len(chunk)} users") - else: - text = await response.text() - log.exception(f"Failed to send {len(chunk)} users", extra={"body": text}) - break # Stop right now, thank you very much - - result = {} - - if replace_all: - response = None - - try: - response = await self.bot.http_session.post( - url=URLs.site_user_complete_api, - headers={"X-API-Key": Keys.site_api} - ) - - result = await response.json() - except Exception: - if not response: - log.exception(f"Failed to send {len(chunk)} users") - else: - text = await response.text() - log.exception(f"Failed to send {len(chunk)} users", extra={"body": text}) - - return result - - async def send_delete_users(self, *users): - try: - response = await self.bot.http_session.delete( - url=URLs.site_user_api, - json=list(users), - headers={"X-API-Key": Keys.site_api} - ) - - return await response.json() - except Exception: - log.exception(f"Failed to delete {len(users)} users") - return {} - - async def get_user(self, user_id): - response = await self.bot.http_session.get( - url=URLs.site_user_api, - params={"user_id": user_id}, - headers={"X-API-Key": Keys.site_api} - ) - - resp = await response.json() - return resp["data"] - - async def has_active_mute(self, user_id: str) -> bool: - """ - Check whether a user has any active mute infractions - """ - - response = await self.bot.http_session.get( - URLs.site_infractions_user.format( - user_id=user_id - ), - params={"hidden": "True"}, - headers=self.headers - ) - infraction_list = await response.json() - - # Check for active mute infractions - if not infraction_list: - # Short circuit - return False - - return any( - infraction["active"] for infraction in infraction_list if infraction["type"].lower() == "mute" - ) - - async def on_command_error(self, ctx: Context, e: CommandError): - command = ctx.command - parent = None - - if command is not None: - parent = command.parent - - if parent and command: - help_command = (self.bot.get_command("help"), parent.name, command.name) - elif command: - help_command = (self.bot.get_command("help"), command.name) - else: - help_command = (self.bot.get_command("help"),) - - if hasattr(command, "on_error"): - log.debug(f"Command {command} has a local error handler, ignoring.") - return - - if isinstance(e, CommandNotFound) and not hasattr(ctx, "invoked_from_error_handler"): - tags_get_command = self.bot.get_command("tags get") - ctx.invoked_from_error_handler = True - - # Return to not raise the exception - return await ctx.invoke(tags_get_command, tag_name=ctx.invoked_with) - elif isinstance(e, BadArgument): - await ctx.send(f"Bad argument: {e}\n") - await ctx.invoke(*help_command) - elif isinstance(e, UserInputError): - await ctx.invoke(*help_command) - elif isinstance(e, NoPrivateMessage): - await ctx.send("Sorry, this command can't be used in a private message!") - elif isinstance(e, BotMissingPermissions): - await ctx.send( - f"Sorry, it looks like I don't have the permissions I need to do that.\n\n" - f"Here's what I'm missing: **{e.missing_perms}**" - ) - elif isinstance(e, CommandInvokeError): - await ctx.send( - f"Sorry, an unexpected error occurred. Please let us know!\n\n```{e}```" - ) - raise e.original - raise e - - async def on_ready(self): - users = [] - - for member in self.bot.get_guild(Guild.id).members: # type: Member - roles: List[int] = [str(r.id) for r in member.roles] - - users.append({ - "avatar": member.avatar_url_as(format="png"), - "user_id": str(member.id), - "roles": roles, - "username": member.name, - "discriminator": member.discriminator - }) - - if users: - log.info(f"{len(users)} user roles to be updated") - - done = await self.send_updated_users(*users, replace_all=True) - - if any(done.values()): - embed = Embed( - title="Users updated" - ) - - for key, value in done.items(): - if value: - if key == "deleted_oauth": - key = "Deleted (OAuth)" - elif key == "deleted_jam_profiles": - key = "Deleted (Jammer Profiles)" - elif key == "deleted_responses": - key = "Deleted (Jam Form Responses)" - elif key == "jam_bans": - key = "Ex-Jammer Bans" - else: - key = key.title() - - embed.add_field( - name=key, value=str(value) - ) - - if not DEBUG_MODE: - await self.bot.get_channel(Channels.devlog).send( - embed=embed - ) - - async def on_member_update(self, before: Member, after: Member): - if ( - before.roles == after.roles - and before.name == after.name - and before.discriminator == after.discriminator - and before.avatar == after.avatar): - return - - before_role_names: List[str] = [role.name for role in before.roles] - after_role_names: List[str] = [role.name for role in after.roles] - role_ids: List[str] = [str(r.id) for r in after.roles] - - log.debug(f"{before.display_name} roles changing from {before_role_names} to {after_role_names}") - - changes = await self.send_updated_users({ - "avatar": after.avatar_url_as(format="png"), - "user_id": str(after.id), - "roles": role_ids, - "username": after.name, - "discriminator": after.discriminator - }) - - log.debug(f"User {after.id} updated; changes: {changes}") - - async def on_member_join(self, member: Member): - role_ids: List[str] = [str(r.id) for r in member.roles] - new_roles = [] - - try: - user_objs = await self.get_user(str(member.id)) - except Exception as e: - log.exception("Failed to persist roles") - - await self.send_log( - Icons.crown_red, Colour(Colours.soft_red), "Failed to persist roles", - f"```py\n{e}\n```", - member.avatar_url_as(static_format="png") - ) - else: - if user_objs: - old_roles = user_objs[0].get("roles", []) - - for role in RESTORE_ROLES: - if role in old_roles: - # Check for mute roles that were not able to be removed and skip if present - if role == str(Roles.muted) and not await self.has_active_mute(str(member.id)): - log.debug( - f"User {member.id} has no active mute infraction, " - "their leftover muted role will not be persisted" - ) - continue - - new_roles.append(Object(int(role))) - - for role in new_roles: - if str(role) not in role_ids: - role_ids.append(str(role.id)) - - changes = await self.send_updated_users({ - "avatar": member.avatar_url_as(format="png"), - "user_id": str(member.id), - "roles": role_ids, - "username": member.name, - "discriminator": member.discriminator - }) - - log.debug(f"User {member.id} joined; changes: {changes}") - - if new_roles: - await member.add_roles( - *new_roles, - reason="Roles restored" - ) - - await self.send_log( - Icons.crown_blurple, Colour.blurple(), "Roles restored", - f"Restored {len(new_roles)} roles", - member.avatar_url_as(static_format="png") - ) - - async def on_member_remove(self, member: Member): - changes = await self.send_delete_users({ - "user_id": str(member.id) - }) - - log.debug(f"User {member.id} left; changes: {changes}") - - -def setup(bot): - bot.add_cog(Events(bot)) - log.info("Cog loaded: Events") diff --git a/bot/cogs/information.py b/bot/cogs/information.py index 469999c00..a2585f395 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -3,11 +3,12 @@ import random import textwrap from discord import CategoryChannel, Colour, Embed, Member, TextChannel, VoiceChannel -from discord.ext.commands import BadArgument, Bot, CommandError, Context, MissingPermissions, command +from discord.ext.commands import ( + BadArgument, Bot, CommandError, Context, MissingPermissions, command +) from bot.constants import ( - Channels, Emojis, Keys, MODERATION_ROLES, - NEGATIVE_REPLIES, STAFF_ROLES, URLs + Channels, Emojis, Keys, MODERATION_ROLES, NEGATIVE_REPLIES, STAFF_ROLES ) from bot.decorators import with_role from bot.utils.checks import with_role_check @@ -166,14 +167,14 @@ class Information: ) # Infractions - api_response = await self.bot.http_session.get( - url=URLs.site_infractions_user.format(user_id=user.id), - params={"hidden": hidden}, - headers=self.headers + infractions = await self.bot.api_client.get( + 'bot/infractions', + params={ + 'hidden': hidden, + 'user__id': str(user.id) + } ) - infractions = await api_response.json() - infr_total = 0 infr_active = 0 diff --git a/bot/cogs/moderation.py b/bot/cogs/moderation.py index 73359c88c..1dc2c70d6 100644 --- a/bot/cogs/moderation.py +++ b/bot/cogs/moderation.py @@ -1,9 +1,9 @@ import asyncio import logging import textwrap +from datetime import datetime from typing import Union -from aiohttp import ClientError from discord import ( Colour, Embed, Forbidden, Guild, HTTPException, Member, Object, User ) @@ -13,13 +13,13 @@ from discord.ext.commands import ( from bot import constants from bot.cogs.modlog import ModLog -from bot.constants import Colours, Event, Icons, Keys, MODERATION_ROLES, URLs -from bot.converters import InfractionSearchQuery +from bot.constants import Colours, Event, Icons, MODERATION_ROLES +from bot.converters import ExpirationDate, InfractionSearchQuery from bot.decorators import with_role from bot.pagination import LinePaginator from bot.utils.moderation import post_infraction from bot.utils.scheduling import Scheduler, create_task -from bot.utils.time import parse_rfc1123, wait_until +from bot.utils.time import wait_until log = logging.getLogger(__name__) @@ -53,7 +53,6 @@ class Moderation(Scheduler): def __init__(self, bot: Bot): self.bot = bot - self.headers = {"X-API-KEY": Keys.site_api} self._muted_role = Object(constants.Roles.muted) super().__init__() @@ -63,15 +62,12 @@ class Moderation(Scheduler): async def on_ready(self): # Schedule expiration for previous infractions - response = await self.bot.http_session.get( - URLs.site_infractions, - params={"dangling": "true"}, - headers=self.headers + infractions = await self.bot.api_client.get( + 'bot/infractions', params={'active': 'true'} ) - infraction_list = await response.json() - for infraction_object in infraction_list: - if infraction_object["expires_at"] is not None: - self.schedule_task(self.bot.loop, infraction_object["id"], infraction_object) + for infraction in infractions: + if infraction["expires_at"] is not None: + self.schedule_task(self.bot.loop, infraction["id"], infraction) # region: Permanent infractions @@ -200,6 +196,20 @@ class Moderation(Scheduler): # Warning is sent to ctx by the helper method return + active_bans = await self.bot.api_client.get( + 'bot/infractions', + params={ + 'active': 'true', + 'type': 'ban', + 'user__id': str(user.id) + } + ) + if active_bans: + return await ctx.send( + ":x: According to my records, this user is already banned. " + f"See infraction **#{active_bans[0]['id']}**." + ) + response_object = await post_infraction(ctx, user, type="ban", reason=reason) if response_object is None: return @@ -207,7 +217,6 @@ class Moderation(Scheduler): notified = await self.notify_infraction( user=user, infr_type="Ban", - duration="Permanent", reason=reason ) @@ -259,6 +268,20 @@ class Moderation(Scheduler): **`reason`:** The reason for the mute. """ + active_mutes = await self.bot.api_client.get( + 'bot/infractions', + params={ + 'active': 'true', + 'type': 'mute', + 'user__id': str(user.id) + } + ) + if active_mutes: + return await ctx.send( + ":x: According to my records, this user is already muted. " + f"See infraction **#{active_mutes[0]['id']}**." + ) + response_object = await post_infraction(ctx, user, type="mute", reason=reason) if response_object is None: return @@ -269,7 +292,7 @@ class Moderation(Scheduler): notified = await self.notify_infraction( user=user, infr_type="Mute", - duration="Permanent", + expires_at="Permanent", reason=reason ) @@ -308,7 +331,10 @@ class Moderation(Scheduler): @with_role(*MODERATION_ROLES) @command() - async def tempmute(self, ctx: Context, user: Member, duration: str, *, reason: str = None): + async def tempmute( + self, ctx: Context, user: Member, expiration: ExpirationDate, + *, reason: str = None + ): """ Create a temporary mute infraction in the database for a user. @@ -317,11 +343,25 @@ class Moderation(Scheduler): **`reason`:** The reason for the temporary mute. """ - response_object = await post_infraction( - ctx, user, type="mute", reason=reason, duration=duration + active_mutes = await self.bot.api_client.get( + 'bot/infractions', + params={ + 'active': 'true', + 'type': 'mute', + 'user__id': str(user.id) + } + ) + if active_mutes: + return await ctx.send( + ":x: According to my records, this user is already muted. " + f"See infraction **#{active_mutes[0]['id']}**." + ) + + infraction = await post_infraction( + ctx, user, + type="mute", reason=reason, + expires_at=expiration ) - if response_object is None: - return self.mod_log.ignore(Event.member_update, user.id) await user.add_roles(self._muted_role, reason=reason) @@ -329,14 +369,17 @@ class Moderation(Scheduler): notified = await self.notify_infraction( user=user, infr_type="Mute", - duration=duration, + expires_at=expiration, reason=reason ) - infraction_object = response_object["infraction"] - infraction_expiration = infraction_object["expires_at"] + infraction_expiration = ( + datetime + .fromisoformat(infraction["expires_at"][:-1]) + .strftime('%c') + ) - self.schedule_task(ctx.bot.loop, infraction_object["id"], infraction_object) + self.schedule_task(ctx.bot.loop, infraction["id"], infraction) dm_result = ":incoming_envelope: " if notified else "" action = f"{dm_result}:ok_hand: muted {user.mention} until {infraction_expiration}" @@ -363,24 +406,23 @@ class Moderation(Scheduler): Actor: {ctx.message.author} DM: {dm_status} Reason: {reason} - Duration: {duration} Expires: {infraction_expiration} """), content=log_content, - footer=f"ID {response_object['infraction']['id']}" + footer=f"ID {infraction['id']}" ) @with_role(*MODERATION_ROLES) @command() async def tempban( - self, ctx: Context, user: UserTypes, duration: str, *, reason: str = None + self, ctx: Context, user: UserTypes, expiry: ExpirationDate, *, reason: str = None ): """ Create a temporary ban infraction in the database for a user. - **`user`:** Accepts user mention, ID, etc. - **`duration`:** The duration for the temporary ban infraction - **`reason`:** The reason for the temporary ban. + **`user`:** Accepts user mention, ID, etc. + **`expiry`:** The duration for the temporary ban infraction + **`reason`:** The reason for the temporary ban. """ if not await self.respect_role_hierarchy(ctx, user, 'tempban'): @@ -388,16 +430,31 @@ class Moderation(Scheduler): # Warning is sent to ctx by the helper method return - response_object = await post_infraction( - ctx, user, type="ban", reason=reason, duration=duration + active_bans = await self.bot.api_client.get( + 'bot/infractions', + params={ + 'active': 'true', + 'type': 'ban', + 'user__id': str(user.id) + } ) - if response_object is None: + if active_bans: + return await ctx.send( + ":x: According to my records, this user is already banned. " + f"See infraction **#{active_bans[0]['id']}**." + ) + + infraction = await post_infraction( + ctx, user, type="ban", + reason=reason, expires_at=expiry + ) + if infraction is None: return notified = await self.notify_infraction( user=user, infr_type="Ban", - duration=duration, + expires_at=expiry, reason=reason ) @@ -410,10 +467,13 @@ class Moderation(Scheduler): except Forbidden: action_result = False - infraction_object = response_object["infraction"] - infraction_expiration = infraction_object["expires_at"] + infraction_expiration = ( + datetime + .fromisoformat(infraction["expires_at"][:-1]) + .strftime('%c') + ) - self.schedule_task(ctx.bot.loop, infraction_object["id"], infraction_object) + self.schedule_task(ctx.bot.loop, infraction["id"], infraction) dm_result = ":incoming_envelope: " if notified else "" action = f"{dm_result}:ok_hand: banned {user.mention} until {infraction_expiration}" @@ -439,11 +499,10 @@ class Moderation(Scheduler): Actor: {ctx.message.author} DM: {dm_status} Reason: {reason} - Duration: {duration} Expires: {infraction_expiration} """), content=log_content, - footer=f"ID {response_object['infraction']['id']}" + footer=f"ID {infraction['id']}" ) # endregion @@ -462,6 +521,7 @@ class Moderation(Scheduler): response_object = await post_infraction( ctx, user, type="warning", reason=reason, hidden=True ) + if response_object is None: return @@ -760,31 +820,27 @@ class Moderation(Scheduler): try: # check the current active infraction - response = await self.bot.http_session.get( - URLs.site_infractions_user_type_current.format( - user_id=user.id, - infraction_type="mute" - ), - headers=self.headers + response = await self.bot.api_client.get( + 'bot/infractions', + params={ + 'active': 'true', + 'type': 'mute', + 'user__id': user.id + } ) + if len(response) > 1: + log.warning("Found more than one active mute infraction for user `%d`", user.id) - response_object = await response.json() - if "error_code" in response_object: - return await ctx.send( - ":x: There was an error removing the infraction: " - f"{response_object['error_message']}" - ) - - infraction_object = response_object["infraction"] - if infraction_object is None: + if not response: # no active infraction return await ctx.send( f":x: There is no active mute infraction for user {user.mention}." ) - await self._deactivate_infraction(infraction_object) - if infraction_object["expires_at"] is not None: - self.cancel_expiration(infraction_object["id"]) + infraction = response[0] + await self._deactivate_infraction(infraction) + if infraction["expires_at"] is not None: + self.cancel_expiration(infraction["id"]) notified = await self.notify_pardon( user=user, @@ -813,15 +869,14 @@ class Moderation(Scheduler): text=textwrap.dedent(f""" Member: {user.mention} (`{user.id}`) Actor: {ctx.message.author} - Intended expiry: {infraction_object['expires_at']} + Intended expiry: {infraction['expires_at']} DM: {dm_status} """), - footer=infraction_object["id"], + footer=infraction["id"], content=log_content ) - - except Exception as e: - log.exception("There was an error removing an infraction.", exc_info=e) + except Exception: + log.exception("There was an error removing an infraction.") await ctx.send(":x: There was an error removing the infraction.") @with_role(*MODERATION_ROLES) @@ -835,30 +890,30 @@ class Moderation(Scheduler): try: # check the current active infraction - response = await self.bot.http_session.get( - URLs.site_infractions_user_type_current.format( - user_id=user.id, - infraction_type="ban" - ), - headers=self.headers + response = await self.bot.api_client.get( + 'bot/infractions', + params={ + 'active': 'true', + 'type': 'ban', + 'user__id': str(user.id) + } ) - response_object = await response.json() - if "error_code" in response_object: - return await ctx.send( - ":x: There was an error removing the infraction: " - f"{response_object['error_message']}" + if len(response) > 1: + log.warning( + "More than one active ban infraction found for user `%d`.", + user.id ) - infraction_object = response_object["infraction"] - if infraction_object is None: + if not response: # no active infraction return await ctx.send( f":x: There is no active ban infraction for user {user.mention}." ) - await self._deactivate_infraction(infraction_object) - if infraction_object["expires_at"] is not None: - self.cancel_expiration(infraction_object["id"]) + infraction = response[0] + await self._deactivate_infraction(infraction) + if infraction["expires_at"] is not None: + self.cancel_expiration(infraction["id"]) await ctx.send(f":ok_hand: Un-banned {user.mention}.") @@ -871,7 +926,7 @@ class Moderation(Scheduler): text=textwrap.dedent(f""" Member: {user.mention} (`{user.id}`) Actor: {ctx.message.author} - Intended expiry: {infraction_object['expires_at']} + Intended expiry: {infraction['expires_at']} """) ) except Exception: @@ -897,56 +952,59 @@ class Moderation(Scheduler): @with_role(*MODERATION_ROLES) @infraction_edit_group.command(name="duration") - async def edit_duration(self, ctx: Context, infraction_id: str, duration: str): + async def edit_duration( + self, ctx: Context, + infraction_id: int, expires_at: Union[ExpirationDate, str] + ): """ - Sets the duration of the given infraction, relative to the time of - updating. + Sets the duration of the given infraction, relative to the time of updating. - **`infraction_id`:** The ID (UUID) of the infraction. - **`duration`:** The new duration of the infraction, relative to the - time of updating. Use "permanent" to the infraction as permanent. + **`infraction_id`:** the id of the infraction + **`expires_at`:** the new expiration date of the infraction. + Use "permanent" to mark the infraction as permanent. """ - try: - previous = await self.bot.http_session.get( - URLs.site_infractions_by_id.format( - infraction_id=infraction_id - ), - headers=self.headers + if isinstance(expires_at, str) and expires_at != 'permanent': + raise BadArgument( + "If `expires_at` is given as a non-datetime, " + "it must be `permanent`." ) + if expires_at == 'permanent': + expires_at = None - previous_object = await previous.json() + try: + previous_infraction = await self.bot.api_client.get( + 'bot/infractions/' + str(infraction_id) + ) - if duration == "permanent": - duration = None # check the current active infraction - response = await self.bot.http_session.patch( - URLs.site_infractions, + infraction = await self.bot.api_client.patch( + 'bot/infractions/' + str(infraction_id), json={ - "id": infraction_id, - "duration": duration - }, - headers=self.headers + 'expires_at': ( + expires_at.isoformat() + if expires_at is not None + else None + ) + } ) - response_object = await response.json() - if "error_code" in response_object or response_object.get("success") is False: - return await ctx.send( - ":x: There was an error updating the infraction: " - f"{response_object['error_message']}" - ) - infraction_object = response_object["infraction"] # Re-schedule - self.cancel_task(infraction_id) + self.cancel_task(infraction['id']) loop = asyncio.get_event_loop() - self.schedule_task(loop, infraction_object["id"], infraction_object) + self.schedule_task(loop, infraction['id'], infraction) - if duration is None: + if expires_at is None: await ctx.send(f":ok_hand: Updated infraction: marked as permanent.") else: + human_expiry = ( + datetime + .fromisoformat(infraction['expires_at'][:-1]) + .strftime('%c') + ) await ctx.send( ":ok_hand: Updated infraction: set to expire on " - f"{infraction_object['expires_at']}." + f"{human_expiry}." ) except Exception: @@ -954,10 +1012,8 @@ class Moderation(Scheduler): await ctx.send(":x: There was an error updating the infraction.") return - prev_infraction = previous_object["infraction"] - # Get information about the infraction's user - user_id = int(infraction_object["user"]["user_id"]) + user_id = infraction["user"] user = ctx.guild.get_member(user_id) if user: @@ -968,7 +1024,7 @@ class Moderation(Scheduler): thumbnail = None # The infraction's actor - actor_id = int(infraction_object["actor"]["user_id"]) + actor_id = infraction["actor"] actor = ctx.guild.get_member(actor_id) or f"`{actor_id}`" await self.mod_log.send_log_message( @@ -980,55 +1036,37 @@ class Moderation(Scheduler): Member: {member_text} Actor: {actor} Edited by: {ctx.message.author} - Previous expiry: {prev_infraction['expires_at']} - New expiry: {infraction_object['expires_at']} + Previous expiry: {previous_infraction['expires_at']} + New expiry: {infraction['expires_at']} """) ) @with_role(*MODERATION_ROLES) @infraction_edit_group.command(name="reason") - async def edit_reason(self, ctx: Context, infraction_id: str, *, reason: str): + async def edit_reason(self, ctx: Context, infraction_id: int, *, reason: str): """ Sets the reason of the given infraction. - **`infraction_id`:** The ID (UUID) of the infraction. - **`reason`:** The new reason of the infraction. + **`infraction_id`:** the id of the infraction + **`reason`:** The new reason of the infraction """ try: - previous = await self.bot.http_session.get( - URLs.site_infractions_by_id.format( - infraction_id=infraction_id - ), - headers=self.headers + old_infraction = await self.bot.api_client.get( + 'bot/infractions/' + str(infraction_id) ) - previous_object = await previous.json() - - response = await self.bot.http_session.patch( - URLs.site_infractions, - json={ - "id": infraction_id, - "reason": reason - }, - headers=self.headers + updated_infraction = await self.bot.api_client.patch( + 'bot/infractions/' + str(infraction_id), + json={'reason': reason} ) - response_object = await response.json() - if "error_code" in response_object or response_object.get("success") is False: - return await ctx.send( - ":x: There was an error updating the infraction: " - f"{response_object['error_message']}" - ) - await ctx.send(f":ok_hand: Updated infraction: set reason to \"{reason}\".") + except Exception: log.exception("There was an error updating an infraction.") return await ctx.send(":x: There was an error updating the infraction.") - new_infraction = response_object["infraction"] - prev_infraction = previous_object["infraction"] - # Get information about the infraction's user - user_id = int(new_infraction["user"]["user_id"]) + user_id = updated_infraction['user'] user = ctx.guild.get_member(user_id) if user: @@ -1039,7 +1077,7 @@ class Moderation(Scheduler): thumbnail = None # The infraction's actor - actor_id = int(new_infraction["actor"]["user_id"]) + actor_id = updated_infraction['actor'] actor = ctx.guild.get_member(actor_id) or f"`{actor_id}`" await self.mod_log.send_log_message( @@ -1051,8 +1089,8 @@ class Moderation(Scheduler): Member: {user_text} Actor: {actor} Edited by: {ctx.message.author} - Previous reason: {prev_infraction['reason']} - New reason: {new_infraction['reason']} + Previous reason: {old_infraction['reason']} + New reason: {updated_infraction['reason']} """) ) @@ -1079,25 +1117,14 @@ class Moderation(Scheduler): Search for infractions by member. """ - try: - response = await self.bot.http_session.get( - URLs.site_infractions_user.format( - user_id=user.id - ), - params={"hidden": "True"}, - headers=self.headers - ) - infraction_list = await response.json() - except ClientError: - log.exception(f"Failed to fetch infractions for user {user} ({user.id}).") - await ctx.send(":x: An error occurred while fetching infractions.") - return - + infraction_list = await self.bot.api_client.get( + 'bot/infractions', + params={'user__id': str(user.id)} + ) embed = Embed( title=f"Infractions for {user} ({len(infraction_list)} total)", colour=Colour.orange() ) - await self.send_infraction_list(ctx, embed, infraction_list) @with_role(*MODERATION_ROLES) @@ -1107,23 +1134,13 @@ class Moderation(Scheduler): Search for infractions by their reason. Use Re2 for matching. """ - try: - response = await self.bot.http_session.get( - URLs.site_infractions, - params={"search": reason, "hidden": "True"}, - headers=self.headers - ) - infraction_list = await response.json() - except ClientError: - log.exception(f"Failed to fetch infractions matching reason `{reason}`.") - await ctx.send(":x: An error occurred while fetching infractions.") - return - + infraction_list = await self.bot.api_client.get( + 'bot/infractions', params={'search': reason} + ) embed = Embed( title=f"Infractions matching `{reason}` ({len(infraction_list)} total)", colour=Colour.orange() ) - await self.send_infraction_list(ctx, embed, infraction_list) # endregion @@ -1135,11 +1152,10 @@ class Moderation(Scheduler): await ctx.send(f":warning: No infractions could be found for that query.") return - lines = [] - for infraction in infractions: - lines.append( - self._infraction_to_string(infraction) - ) + lines = tuple( + self._infraction_to_string(infraction) + for infraction in infractions + ) await LinePaginator.paginate( lines, @@ -1195,7 +1211,7 @@ class Moderation(Scheduler): infraction_id = infraction_object["id"] # transform expiration to delay in seconds - expiration_datetime = parse_rfc1123(infraction_object["expires_at"]) + expiration_datetime = datetime.fromisoformat(infraction_object["expires_at"][:-1]) await wait_until(expiration_datetime) log.debug(f"Marking infraction {infraction_id} as inactive (expired).") @@ -1204,7 +1220,7 @@ class Moderation(Scheduler): self.cancel_task(infraction_object["id"]) # Notify the user that they've been unmuted. - user_id = int(infraction_object["user"]["user_id"]) + user_id = infraction_object["user"] guild = self.bot.get_guild(constants.Guild.id) await self.notify_pardon( user=guild.get_member(user_id), @@ -1215,14 +1231,14 @@ class Moderation(Scheduler): async def _deactivate_infraction(self, infraction_object): """ - A co-routine which marks an infraction as inactive on the website. This co-routine does - not cancel or un-schedule an expiration task. + A co-routine which marks an infraction as inactive on the website. + This co-routine does not cancel or un-schedule an expiration task. :param infraction_object: the infraction in question """ guild: Guild = self.bot.get_guild(constants.Guild.id) - user_id = int(infraction_object["user"]["user_id"]) + user_id = infraction_object["user"] infraction_type = infraction_object["type"] if infraction_type == "mute": @@ -1237,22 +1253,18 @@ class Moderation(Scheduler): user: Object = Object(user_id) await guild.unban(user) - await self.bot.http_session.patch( - URLs.site_infractions, - headers=self.headers, - json={ - "id": infraction_object["id"], - "active": False - } + await self.bot.api_client.patch( + 'bot/infractions/' + str(infraction_object['id']), + json={"active": False} ) def _infraction_to_string(self, infraction_object): - actor_id = int(infraction_object["actor"]["user_id"]) + actor_id = infraction_object["actor"] guild: Guild = self.bot.get_guild(constants.Guild.id) actor = guild.get_member(actor_id) - active = infraction_object["active"] is True - user_id = int(infraction_object["user"]["user_id"]) - hidden = infraction_object.get("hidden", False) is True + active = infraction_object["active"] + user_id = infraction_object["user"] + hidden = infraction_object["hidden"] lines = textwrap.dedent(f""" {"**===============**" if active else "==============="} @@ -1271,8 +1283,8 @@ class Moderation(Scheduler): return lines.strip() async def notify_infraction( - self, user: Union[User, Member], infr_type: str, duration: str = None, - reason: str = None + self, user: Union[User, Member], infr_type: str, + expires_at: Union[datetime, str] = 'N/A', reason: str = "No reason provided." ): """ Notify a user of their fresh infraction :) @@ -1283,16 +1295,13 @@ class Moderation(Scheduler): :param reason: The reason for the infraction. """ - if duration is None: - duration = "N/A" - - if reason is None: - reason = "No reason provided." + if isinstance(expires_at, datetime): + expires_at = expires_at.strftime('%c') embed = Embed( description=textwrap.dedent(f""" **Type:** {infr_type} - **Duration:** {duration} + **Expires:** {expires_at} **Reason:** {reason} """), colour=Colour(Colours.soft_red) diff --git a/bot/cogs/modlog.py b/bot/cogs/modlog.py index b3094321e..9f0c88424 100644 --- a/bot/cogs/modlog.py +++ b/bot/cogs/modlog.py @@ -1,9 +1,8 @@ import asyncio -import datetime import logging +from datetime import datetime from typing import List, Optional, Union -from aiohttp import ClientResponseError from dateutil.relativedelta import relativedelta from deepdiff import DeepDiff from discord import ( @@ -15,9 +14,7 @@ from discord.abc import GuildChannel from discord.ext.commands import Bot from bot.constants import ( - Channels, Colours, Emojis, - Event, Guild as GuildConstant, Icons, - Keys, Roles, URLs + Channels, Colours, Emojis, Event, Guild as GuildConstant, Icons, URLs ) from bot.utils.time import humanize_delta @@ -38,13 +35,12 @@ class ModLog: def __init__(self, bot: Bot): self.bot = bot - self.headers = {"X-API-KEY": Keys.site_api} self._ignored = {event: [] for event in Event} self._cached_deletes = [] self._cached_edits = [] - async def upload_log(self, messages: List[Message]) -> Optional[str]: + async def upload_log(self, messages: List[Message], actor_id: int) -> Optional[str]: """ Uploads the log data to the database via an API endpoint for uploading logs. @@ -54,48 +50,25 @@ class ModLog: Returns a URL that can be used to view the log. """ - log_data = [] - - for message in messages: - author = f"{message.author.name}#{message.author.discriminator}" - - # message.author may return either a User or a Member. Users don't have roles. - if type(message.author) is User: - role_id = Roles.developer - else: - role_id = message.author.top_role.id - - content = message.content - embeds = [embed.to_dict() for embed in message.embeds] - attachments = ["<Attachment>" for _ in message.attachments] - - log_data.append({ - "content": content, - "author": author, - "user_id": str(message.author.id), - "role_id": str(role_id), - "timestamp": message.created_at.strftime("%D %H:%M"), - "attachments": attachments, - "embeds": embeds, - }) - - response = await self.bot.http_session.post( - URLs.site_logs_api, - headers=self.headers, - json={"log_data": log_data} + response = await self.bot.api_client.post( + 'bot/deleted-messages', + json={ + 'actor': actor_id, + 'creation': datetime.utcnow().isoformat(), + 'deletedmessage_set': [ + { + 'id': message.id, + 'author': message.author.id, + 'channel_id': message.channel.id, + 'content': message.content, + 'embeds': [embed.to_dict() for embed in message.embeds] + } + for message in messages + ] + } ) - try: - data = await response.json() - log_id = data["log_id"] - except (KeyError, ClientResponseError): - log.debug( - "API returned an unexpected result:\n" - f"{response.text}" - ) - return - - return f"{URLs.site_logs_view}/{log_id}" + return f"{URLs.site_logs_view}/{response['id']}" def ignore(self, event: Event, *items: int): for item in items: @@ -115,7 +88,7 @@ class ModLog: content: Optional[str] = None, additional_embeds: Optional[List[Embed]] = None, additional_embeds_msg: Optional[str] = None, - timestamp_override: Optional[datetime.datetime] = None, + timestamp_override: Optional[datetime] = None, footer: Optional[str] = None, ): embed = Embed(description=text) @@ -124,8 +97,7 @@ class ModLog: embed.set_author(name=title, icon_url=icon_url) embed.colour = colour - - embed.timestamp = timestamp_override or datetime.datetime.utcnow() + embed.timestamp = timestamp_override or datetime.utcnow() if footer: embed.set_footer(text=footer) @@ -391,7 +363,7 @@ class ModLog: return message = f"{member.name}#{member.discriminator} (`{member.id}`)" - now = datetime.datetime.utcnow() + now = datetime.utcnow() difference = abs(relativedelta(now, member.created_at)) message += "\n\n**Account age:** " + humanize_delta(difference) diff --git a/bot/cogs/off_topic_names.py b/bot/cogs/off_topic_names.py index 9b0f5d6c5..c0d2e5dc5 100644 --- a/bot/cogs/off_topic_names.py +++ b/bot/cogs/off_topic_names.py @@ -5,7 +5,7 @@ from datetime import datetime, timedelta from discord import Colour, Embed from discord.ext.commands import BadArgument, Bot, Context, Converter, group -from bot.constants import Channels, Keys, MODERATION_ROLES, URLs +from bot.constants import Channels, Keys, MODERATION_ROLES from bot.decorators import with_role from bot.pagination import LinePaginator @@ -44,7 +44,7 @@ async def update_names(bot: Bot, headers: dict): Args: bot (Bot): The running bot instance, used for fetching data from the - website via the bot's `http_session`. + website via the bot's `api_client`. """ while True: @@ -55,11 +55,9 @@ async def update_names(bot: Bot, headers: dict): seconds_to_sleep = (next_midnight - datetime.utcnow()).seconds + 1 await asyncio.sleep(seconds_to_sleep) - response = await bot.http_session.get( - f'{URLs.site_off_topic_names_api}?random_items=3', - headers=headers + channel_0_name, channel_1_name, channel_2_name = await bot.api_client.get( + 'bot/off-topic-channel-names', params={'random_items': 3} ) - channel_0_name, channel_1_name, channel_2_name = await response.json() channel_0, channel_1, channel_2 = (bot.get_channel(channel_id) for channel_id in CHANNELS) await channel_0.edit(name=f'ot0-{channel_0_name}') @@ -100,49 +98,24 @@ class OffTopicNames: async def add_command(self, ctx, name: OffTopicName): """Adds a new off-topic name to the rotation.""" - result = await self.bot.http_session.post( - URLs.site_off_topic_names_api, - headers=self.headers, - params={'name': name} + await self.bot.api_client.post(f'bot/off-topic-channel-names', params={'name': name}) + log.info( + f"{ctx.author.name}#{ctx.author.discriminator}" + f" added the off-topic channel name '{name}" ) - - response = await result.json() - - if result.status == 200: - log.info( - f"{ctx.author.name}#{ctx.author.discriminator}" - f" added the off-topic channel name '{name}" - ) - await ctx.send(":ok_hand:") - else: - error_reason = response.get('message', "No reason provided.") - await ctx.send(f":warning: got non-200 from the API: {error_reason}") + await ctx.send(":ok_hand:") @otname_group.command(name='delete', aliases=('remove', 'rm', 'del', 'd')) @with_role(*MODERATION_ROLES) async def delete_command(self, ctx, name: OffTopicName): """Removes a off-topic name from the rotation.""" - result = await self.bot.http_session.delete( - URLs.site_off_topic_names_api, - headers=self.headers, - params={'name': name} + await self.bot.api_client.delete(f'bot/off-topic-channel-names/{name}') + log.info( + f"{ctx.author.name}#{ctx.author.discriminator}" + f" deleted the off-topic channel name '{name}" ) - - response = await result.json() - - if result.status == 200: - if response['deleted'] == 0: - await ctx.send(f":warning: No name matching `{name}` was found in the database.") - else: - log.info( - f"{ctx.author.name}#{ctx.author.discriminator}" - f" deleted the off-topic channel name '{name}" - ) - await ctx.send(":ok_hand:") - else: - error_reason = response.get('message', "No reason provided.") - await ctx.send(f":warning: got non-200 from the API: {error_reason}") + await ctx.send(":ok_hand:") @otname_group.command(name='list', aliases=('l',)) @with_role(*MODERATION_ROLES) @@ -152,18 +125,17 @@ class OffTopicNames: Restricted to Moderator and above to not spoil the surprise. """ - result = await self.bot.http_session.get( - URLs.site_off_topic_names_api, - headers=self.headers - ) - response = await result.json() - lines = sorted(f"• {name}" for name in response) - + result = await self.bot.api_client.get('bot/off-topic-channel-names') + lines = sorted(f"• {name}" for name in result) embed = Embed( - title=f"Known off-topic names (`{len(response)}` total)", + title=f"Known off-topic names (`{len(result)}` total)", colour=Colour.blue() ) - await LinePaginator.paginate(lines, ctx, embed, max_size=400, empty=False) + if result: + await LinePaginator.paginate(lines, ctx, embed, max_size=400, empty=False) + else: + embed.description = "Hmmm, seems like there's nothing here yet." + await ctx.send(embed=embed) def setup(bot: Bot): diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index e8177107b..03ea00de8 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -1,22 +1,20 @@ import asyncio -import datetime import logging import random import textwrap +from datetime import datetime +from operator import itemgetter -from aiohttp import ClientResponseError from dateutil.relativedelta import relativedelta from discord import Colour, Embed from discord.ext.commands import Bot, Context, group -from bot.constants import ( - Channels, Icons, Keys, NEGATIVE_REPLIES, - POSITIVE_REPLIES, STAFF_ROLES, URLs -) +from bot.constants import Channels, Icons, NEGATIVE_REPLIES, POSITIVE_REPLIES, STAFF_ROLES +from bot.converters import ExpirationDate from bot.pagination import LinePaginator from bot.utils.checks import without_role_check from bot.utils.scheduling import Scheduler -from bot.utils.time import humanize_delta, parse_rfc1123, wait_until +from bot.utils.time import humanize_delta, wait_until log = logging.getLogger(__name__) @@ -28,24 +26,20 @@ class Reminders(Scheduler): def __init__(self, bot: Bot): self.bot = bot - self.headers = {"X-API-Key": Keys.site_api} super().__init__() async def on_ready(self): # Get all the current reminders for re-scheduling - response = await self.bot.http_session.get( - url=URLs.site_reminders_api, - headers=self.headers + response = await self.bot.api_client.get( + 'bot/reminders', + params={'active': 'true'} ) - response_data = await response.json() - - # Find the current time, timezone-aware. - now = datetime.datetime.utcnow().replace(tzinfo=datetime.timezone.utc) + now = datetime.utcnow() loop = asyncio.get_event_loop() - for reminder in response_data["reminders"]: - remind_at = parse_rfc1123(reminder["remind_at"]) + for reminder in response: + remind_at = datetime.fromisoformat(reminder['expiration'][:-1]) # If the reminder is already overdue ... if remind_at < now: @@ -56,32 +50,16 @@ class Reminders(Scheduler): self.schedule_task(loop, reminder["id"], reminder) @staticmethod - async def _send_confirmation(ctx: Context, response: dict, on_success: str): + async def _send_confirmation(ctx: Context, on_success: str): """ - Send an embed confirming whether or not a change was made successfully. - - :return: A Boolean value indicating whether it failed (True) or passed (False) + Send an embed confirming the change was made successfully. """ embed = Embed() - - if not response.get("success"): - embed.colour = Colour.red() - embed.title = random.choice(NEGATIVE_REPLIES) - embed.description = response.get("error_message", "An unexpected error occurred.") - - log.warn(f"Unable to create/edit/delete a reminder. Response: {response}") - failed = True - - else: - embed.colour = Colour.green() - embed.title = random.choice(POSITIVE_REPLIES) - embed.description = on_success - - failed = False - + embed.colour = Colour.green() + embed.title = random.choice(POSITIVE_REPLIES) + embed.description = on_success await ctx.send(embed=embed) - return failed async def _scheduled_task(self, reminder: dict): """ @@ -92,7 +70,7 @@ class Reminders(Scheduler): """ reminder_id = reminder["id"] - reminder_datetime = parse_rfc1123(reminder["remind_at"]) + reminder_datetime = datetime.fromisoformat(reminder['expiration'][:-1]) # Send the reminder message once the desired duration has passed await wait_until(reminder_datetime) @@ -111,18 +89,7 @@ class Reminders(Scheduler): :param reminder_id: The ID of the reminder. """ - # The API requires a list, so let's give it one :) - json_data = { - "reminders": [ - reminder_id - ] - } - - await self.bot.http_session.delete( - url=URLs.site_reminders_api, - headers=self.headers, - json=json_data - ) + await self.bot.api_client.delete('bot/reminders/' + str(reminder_id)) # Now we can remove it from the schedule list self.cancel_task(reminder_id) @@ -147,8 +114,8 @@ class Reminders(Scheduler): :param late: How late the reminder is (if at all) """ - channel = self.bot.get_channel(int(reminder["channel_id"])) - user = self.bot.get_user(int(reminder["user_id"])) + channel = self.bot.get_channel(reminder["channel_id"]) + user = self.bot.get_user(reminder["author"]) embed = Embed() embed.colour = Colour.blurple() @@ -173,15 +140,15 @@ class Reminders(Scheduler): await self._delete_reminder(reminder["id"]) @group(name="remind", aliases=("reminder", "reminders"), invoke_without_command=True) - async def remind_group(self, ctx: Context, duration: str, *, content: str): + async def remind_group(self, ctx: Context, expiration: ExpirationDate, *, content: str): """ Commands for managing your reminders. """ - await ctx.invoke(self.new_reminder, duration=duration, content=content) + await ctx.invoke(self.new_reminder, expiration=expiration, content=content) @remind_group.command(name="new", aliases=("add", "create")) - async def new_reminder(self, ctx: Context, duration: str, *, content: str): + async def new_reminder(self, ctx: Context, expiration: ExpirationDate, *, content: str): """ Set yourself a simple reminder. """ @@ -200,13 +167,13 @@ class Reminders(Scheduler): return await ctx.send(embed=embed) # Get their current active reminders - response = await self.bot.http_session.get( - url=URLs.site_reminders_user_api.format(user_id=ctx.author.id), - headers=self.headers + active_reminders = await self.bot.api_client.get( + 'bot/reminders', + params={ + 'user__id': str(ctx.author.id) + } ) - active_reminders = await response.json() - # Let's limit this, so we don't get 10 000 # reminders from kip or something like that :P if len(active_reminders) > MAXIMUM_REMINDERS: @@ -217,45 +184,23 @@ class Reminders(Scheduler): return await ctx.send(embed=embed) # Now we can attempt to actually set the reminder. - try: - response = await self.bot.http_session.post( - url=URLs.site_reminders_api, - headers=self.headers, - json={ - "user_id": str(ctx.author.id), - "duration": duration, - "content": content, - "channel_id": str(ctx.channel.id) - } - ) - - response_data = await response.json() - - # AFAIK only happens if the user enters, like, a quintillion weeks - except ClientResponseError: - embed.colour = Colour.red() - embed.title = random.choice(NEGATIVE_REPLIES) - embed.description = ( - "An error occurred while adding your reminder to the database. " - "Did you enter a reasonable duration?" - ) - - log.warn(f"User {ctx.author} attempted to create a reminder for {duration}, but failed.") - - return await ctx.send(embed=embed) - - # Confirm to the user whether or not it worked. - failed = await self._send_confirmation( - ctx, response_data, - on_success="Your reminder has been created successfully!" + reminder = await self.bot.api_client.post( + 'bot/reminders', + json={ + 'author': ctx.author.id, + 'channel_id': ctx.message.channel.id, + 'content': content, + 'expiration': expiration.isoformat() + } ) - # If it worked, schedule the reminder. - if not failed: - loop = asyncio.get_event_loop() - reminder = response_data["reminder"] + # Confirm to the user that it worked. + await self._send_confirmation( + ctx, on_success="Your reminder has been created successfully!" + ) - self.schedule_task(loop, reminder["id"], reminder) + loop = asyncio.get_event_loop() + self.schedule_task(loop, reminder["id"], reminder) @remind_group.command(name="list") async def list_reminders(self, ctx: Context): @@ -264,31 +209,31 @@ class Reminders(Scheduler): """ # Get all the user's reminders from the database. - response = await self.bot.http_session.get( - url=URLs.site_reminders_user_api, - params={"user_id": str(ctx.author.id)}, - headers=self.headers + data = await self.bot.api_client.get( + 'bot/reminders', + params={'user__id': str(ctx.author.id)} ) - data = await response.json() - now = datetime.datetime.utcnow().replace(tzinfo=datetime.timezone.utc) + now = datetime.utcnow() # Make a list of tuples so it can be sorted by time. - reminders = [ - (rem["content"], rem["remind_at"], rem["friendly_id"]) for rem in data["reminders"] - ] - - reminders.sort(key=lambda rem: rem[1]) + reminders = sorted( + ( + (rem['content'], rem['expiration'], rem['id']) + for rem in data + ), + key=itemgetter(1) + ) lines = [] - for index, (content, remind_at, friendly_id) in enumerate(reminders): + for content, remind_at, id_ in reminders: # Parse and humanize the time, make it pretty :D - remind_datetime = parse_rfc1123(remind_at) + remind_datetime = datetime.fromisoformat(remind_at[:-1]) time = humanize_delta(relativedelta(remind_datetime, now)) text = textwrap.dedent(f""" - **Reminder #{index}:** *expires in {time}* (ID: {friendly_id}) + **Reminder #{id_}:** *expires in {time}* (ID: {id_}) {content} """).strip() @@ -322,84 +267,53 @@ class Reminders(Scheduler): await ctx.invoke(self.bot.get_command("help"), "reminders", "edit") @edit_reminder_group.command(name="duration", aliases=("time",)) - async def edit_reminder_duration(self, ctx: Context, friendly_id: str, duration: str): + async def edit_reminder_duration(self, ctx: Context, id_: int, expiration: ExpirationDate): """ - Edit one of your reminders' duration. + Edit one of your reminders' expiration. """ # Send the request to update the reminder in the database - response = await self.bot.http_session.patch( - url=URLs.site_reminders_user_api, - headers=self.headers, - json={ - "user_id": str(ctx.author.id), - "friendly_id": friendly_id, - "duration": duration - } + reminder = await self.bot.api_client.patch( + 'bot/reminders/' + str(id_), + json={'expiration': expiration.isoformat()} ) # Send a confirmation message to the channel - response_data = await response.json() - failed = await self._send_confirmation( - ctx, response_data, - on_success="That reminder has been edited successfully!" + await self._send_confirmation( + ctx, on_success="That reminder has been edited successfully!" ) - if not failed: - await self._reschedule_reminder(response_data["reminder"]) + await self._reschedule_reminder(reminder) @edit_reminder_group.command(name="content", aliases=("reason",)) - async def edit_reminder_content(self, ctx: Context, friendly_id: str, *, content: str): + async def edit_reminder_content(self, ctx: Context, id_: int, *, content: str): """ Edit one of your reminders' content. """ # Send the request to update the reminder in the database - response = await self.bot.http_session.patch( - url=URLs.site_reminders_user_api, - headers=self.headers, - json={ - "user_id": str(ctx.author.id), - "friendly_id": friendly_id, - "content": content - } + reminder = await self.bot.api_client.patch( + 'bot/reminders/' + str(id_), + json={'content': content} ) # Send a confirmation message to the channel - response_data = await response.json() - failed = await self._send_confirmation( - ctx, response_data, - on_success="That reminder has been edited successfully!" + await self._send_confirmation( + ctx, on_success="That reminder has been edited successfully!" ) - - if not failed: - await self._reschedule_reminder(response_data["reminder"]) + await self._reschedule_reminder(reminder) @remind_group.command("delete", aliases=("remove",)) - async def delete_reminder(self, ctx: Context, friendly_id: str): + async def delete_reminder(self, ctx: Context, id_: int): """ Delete one of your active reminders. """ - # Send the request to delete the reminder from the database - response = await self.bot.http_session.delete( - url=URLs.site_reminders_user_api, - headers=self.headers, - json={ - "user_id": str(ctx.author.id), - "friendly_id": friendly_id - } - ) - - response_data = await response.json() - failed = await self._send_confirmation( - ctx, response_data, - on_success="That reminder has been deleted successfully!" + await self._delete_reminder(id_) + await self._send_confirmation( + ctx, on_success="That reminder has been deleted successfully!" ) - if not failed: - await self._delete_reminder(response_data["reminder_id"]) - def setup(bot: Bot): bot.add_cog(Reminders(bot)) diff --git a/bot/cogs/rmq.py b/bot/cogs/rmq.py deleted file mode 100644 index 585eacc25..000000000 --- a/bot/cogs/rmq.py +++ /dev/null @@ -1,229 +0,0 @@ -import asyncio -import datetime -import json -import logging -import pprint - -import aio_pika -from aio_pika import Message -from dateutil import parser as date_parser -from discord import Colour, Embed -from discord.ext.commands import Bot -from discord.utils import get - -from bot.constants import Channels, Guild, RabbitMQ - -log = logging.getLogger(__name__) - -LEVEL_COLOURS = { - "debug": Colour.blue(), - "info": Colour.green(), - "warning": Colour.gold(), - "error": Colour.red() -} - -DEFAULT_LEVEL_COLOUR = Colour.greyple() -EMBED_PARAMS = ( - "colour", "title", "url", "description", "timestamp" -) - -CONSUME_TIMEOUT = datetime.timedelta(seconds=10) - - -class RMQ: - """ - RabbitMQ event handling - """ - - rmq = None # type: aio_pika.Connection - channel = None # type: aio_pika.Channel - queue = None # type: aio_pika.Queue - - def __init__(self, bot: Bot): - self.bot = bot - - async def on_ready(self): - self.rmq = await aio_pika.connect_robust( - host=RabbitMQ.host, port=RabbitMQ.port, login=RabbitMQ.username, password=RabbitMQ.password - ) - - log.info("Connected to RabbitMQ") - - self.channel = await self.rmq.channel() - self.queue = await self.channel.declare_queue("bot_events", durable=True) - - log.debug("Channel opened, queue declared") - - async for message in self.queue: - with message.process(): - message.ack() - await self.handle_message(message, message.body.decode()) - - async def send_text(self, queue: str, data: str): - message = Message(data.encode("utf-8")) - await self.channel.default_exchange.publish(message, queue) - - async def send_json(self, queue: str, **data): - message = Message(json.dumps(data).encode("utf-8")) - await self.channel.default_exchange.publish(message, queue) - - async def consume(self, queue: str, **kwargs): - queue_obj = await self.channel.declare_queue(queue, **kwargs) - - result = None - start_time = datetime.datetime.now() - - while result is None: - if datetime.datetime.now() - start_time >= CONSUME_TIMEOUT: - result = "Timed out while waiting for a response." - else: - result = await queue_obj.get(timeout=5, fail=False) - await asyncio.sleep(0.5) - - if result: - result.ack() - - return result - - async def handle_message(self, message, data): - log.debug(f"Message: {message}") - log.debug(f"Data: {data}") - - try: - data = json.loads(data) - except Exception: - await self.do_mod_log("error", "Unable to parse event", data) - else: - event = data["event"] - event_data = data["data"] - - try: - func = getattr(self, f"do_{event}") - await func(**event_data) - except Exception as e: - await self.do_mod_log( - "error", f"Unable to handle event: {event}", - str(e) - ) - - async def do_mod_log(self, level: str, title: str, message: str): - colour = LEVEL_COLOURS.get(level, DEFAULT_LEVEL_COLOUR) - embed = Embed( - title=title, description=f"```\n{message}\n```", - colour=colour, timestamp=datetime.datetime.utcnow() - ) - - await self.bot.get_channel(Channels.devlog).send(embed=embed) - log.log(logging._nameToLevel[level.upper()], f"Modlog: {title} | {message}") - - async def do_send_message(self, target: int, message: str): - channel = self.bot.get_channel(target) - - if channel is None: - await self.do_mod_log( - "error", "Failed: Send Message", - f"Unable to find channel: {target}" - ) - else: - await channel.send(message) - - await self.do_mod_log( - "info", "Succeeded: Send Embed", - f"Message sent to channel {target}\n\n{message}" - ) - - async def do_send_embed(self, target: int, **embed_params): - for param, value in list(embed_params.items()): # To keep a full copy - if param not in EMBED_PARAMS: - await self.do_mod_log( - "warning", "Warning: Send Embed", - f"Unknown embed parameter: {param}" - ) - del embed_params[param] - - if param == "timestamp": - embed_params[param] = date_parser.parse(value) - elif param == "colour": - embed_params[param] = Colour(value) - - channel = self.bot.get_channel(target) - - if channel is None: - await self.do_mod_log( - "error", "Failed: Send Embed", - f"Unable to find channel: {target}" - ) - else: - await channel.send(embed=Embed(**embed_params)) - - await self.do_mod_log( - "info", "Succeeded: Send Embed", - f"Embed sent to channel {target}\n\n{pprint.pformat(embed_params, 4)}" - ) - - async def do_add_role(self, target: int, role_id: int, reason: str): - guild = self.bot.get_guild(Guild.id) - member = guild.get_member(int(target)) - - if member is None: - return await self.do_mod_log( - "error", "Failed: Add Role", - f"Unable to find member: {target}" - ) - - role = get(guild.roles, id=int(role_id)) - - if role is None: - return await self.do_mod_log( - "error", "Failed: Add Role", - f"Unable to find role: {role_id}" - ) - - try: - await member.add_roles(role, reason=reason) - except Exception as e: - await self.do_mod_log( - "error", "Failed: Add Role", - f"Error while adding role {role.name}: {e}" - ) - else: - await self.do_mod_log( - "info", "Succeeded: Add Role", - f"Role {role.name} added to member {target}" - ) - - async def do_remove_role(self, target: int, role_id: int, reason: str): - guild = self.bot.get_guild(Guild.id) - member = guild.get_member(int(target)) - - if member is None: - return await self.do_mod_log( - "error", "Failed: Remove Role", - f"Unable to find member: {target}" - ) - - role = get(guild.roles, id=int(role_id)) - - if role is None: - return await self.do_mod_log( - "error", "Failed: Remove Role", - f"Unable to find role: {role_id}" - ) - - try: - await member.remove_roles(role, reason=reason) - except Exception as e: - await self.do_mod_log( - "error", "Failed: Remove Role", - f"Error while adding role {role.name}: {e}" - ) - else: - await self.do_mod_log( - "info", "Succeeded: Remove Role", - f"Role {role.name} removed from member {target}" - ) - - -def setup(bot): - bot.add_cog(RMQ(bot)) - log.info("Cog loaded: RMQ") diff --git a/bot/cogs/rules.py b/bot/cogs/rules.py deleted file mode 100644 index b8a26ff76..000000000 --- a/bot/cogs/rules.py +++ /dev/null @@ -1,104 +0,0 @@ -import re -from typing import Optional - -from discord import Colour, Embed -from discord.ext.commands import Bot, Context, command - -from bot.constants import Channels, STAFF_ROLES -from bot.decorators import redirect_output -from bot.pagination import LinePaginator - - -class Rules: - - def __init__(self, bot: Bot): - self.bot = bot - - # We'll get the rules from the API when the - # site has been updated to the Django Framework. - # Hard-code the rules for now until the new RulesView is released. - - self.rules = ( - "Be polite, and do not spam.", - - "Follow the [Discord Community Guidelines](https://discordapp.com/guidelines).", - - "Don't intentionally make other people uncomfortable - if someone asks you to stop " - "discussing something, you should stop.", - - "Be patient both with users asking questions, and the users answering them.", - - "We will not help you with anything that might break a law or the terms of service " - "of any other community, site, service, or otherwise - No piracy, brute-forcing, " - "captcha circumvention, sneaker bots, or anything else of that nature.", - - "Listen to and respect the staff members - we're here to help, but we're all human " - "beings.", - - "All discussion should be kept within the relevant channels for the subject - See the " - "[channels page](https://pythondiscord.com/about/channels) for more information.", - - "This is an English-speaking server, so please speak English to the best of your " - "ability - [Google Translate](https://translate.google.com/) should be fine if you're " - "not sure.", - - "Keep all discussions safe for work - No gore, nudity, sexual soliciting, references " - "to suicide, or anything else of that nature", - - "We do not allow advertisements for communities (including other Discord servers) or " - "commercial projects - Contact us directly if you want to discuss a partnership!" - ) - self.default_desc = ("The rules and guidelines that apply to this community can be found on" - " our [rules page](https://pythondiscord.com/about/rules). We expect" - " all members of the community to have read and understood these." - ) - self.title_link = 'https://pythondiscord.com/about/rules' - - @command(aliases=['r', 'rule'], name='rules') - @redirect_output(destination_channel=Channels.bot, bypass_roles=STAFF_ROLES) - async def rules_command(self, ctx: Context, *, rules: Optional[str] = None): - """ - Provides a link to the `rules` endpoint of the website, or displays - specific rules, if they are requested. - - **`ctx`:** The Discord message context - **`rules`:** The rules a user wants to get. - """ - rules_embed = Embed(title='Rules', color=Colour.blurple()) - - if not rules: - # Rules were not submitted. Return the default description. - rules_embed.description = self.default_desc - rules_embed.url = 'https://pythondiscord.com/about/rules' - return await ctx.send(embed=rules_embed) - - # Split the rules input by slash, comma or space - # Returns a list of ints if they're in range of rules index - rules_to_get = [] - split_rules = re.split(r'[/, ]', rules) - for item in split_rules: - if not item.isdigit(): - if not item: - continue - rule_match = re.search(r'\d?\d[:|-]1?\d', item) - if rule_match: - a, b = sorted([int(x)-1 for x in re.split(r'[:-]', rule_match.group())]) - rules_to_get.extend(range(a, b+1)) - else: - rules_to_get.append(int(item)-1) - final_rules = [ - f'**{i+1}.** {self.rules[i]}' for i in sorted(rules_to_get) if i < len(self.rules) - ] - - if not final_rules: - # No valid rules in rules input. Return the default description. - rules_embed.description = self.default_desc - return await ctx.send(embed=rules_embed) - await LinePaginator.paginate( - final_rules, ctx, rules_embed, - max_lines=3, url=self.title_link - ) - - -def setup(bot): - bot.add_cog(Rules(bot)) diff --git a/bot/cogs/site.py b/bot/cogs/site.py index e5fd645fb..37bf4f4ea 100644 --- a/bot/cogs/site.py +++ b/bot/cogs/site.py @@ -3,7 +3,9 @@ import logging from discord import Colour, Embed from discord.ext.commands import Bot, Context, group -from bot.constants import URLs +from bot.constants import Channels, STAFF_ROLES, URLs +from bot.decorators import redirect_output +from bot.pagination import LinePaginator log = logging.getLogger(__name__) @@ -92,6 +94,46 @@ class Site: await ctx.send(embed=embed) + @site_group.command(aliases=['r', 'rule'], name='rules') + @redirect_output(destination_channel=Channels.bot, bypass_roles=STAFF_ROLES) + async def site_rules(self, ctx: Context, *rules: int): + """ + Provides a link to the `rules` endpoint of the website, or displays + specific rules, if they are requested. + + **`ctx`:** The Discord message context + **`rules`:** The rules a user wants to get. + """ + rules_embed = Embed(title='Rules', color=Colour.blurple()) + rules_embed.url = f"{URLs.site_schema}{URLs.site}/about/rules" + + if not rules: + # Rules were not submitted. Return the default description. + rules_embed.description = ( + "The rules and guidelines that apply to this community can be found on" + " our [rules page](https://pythondiscord.com/about/rules). We expect" + " all members of the community to have read and understood these." + ) + + await ctx.send(embed=rules_embed) + return + + full_rules = await self.bot.api_client.get('rules', params={'link_format': 'md'}) + invalid_indices = tuple( + pick + for pick in rules + if pick < 0 or pick >= len(full_rules) + ) + + if invalid_indices: + indices = ', '.join(map(str, invalid_indices)) + await ctx.send(f":x: Invalid rule indices {indices}") + return + + final_rules = tuple(f"**{pick}.** {full_rules[pick]}" for pick in rules) + + await LinePaginator.paginate(final_rules, ctx, rules_embed, max_lines=3) + def setup(bot): bot.add_cog(Site(bot)) diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index cc18c0041..05834e421 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -3,13 +3,14 @@ import logging import random import re import textwrap +from signal import Signals +from typing import Optional, Tuple from discord import Colour, Embed from discord.ext.commands import ( Bot, CommandError, Context, NoPrivateMessage, command, guild_only ) -from bot.cogs.rmq import RMQ from bot.constants import Channels, ERROR_REPLIES, NEGATIVE_REPLIES, STAFF_ROLES, URLs from bot.decorators import InChannelCheckFailure, in_channel from bot.utils.messages import wait_for_deletion @@ -17,23 +18,6 @@ from bot.utils.messages import wait_for_deletion log = logging.getLogger(__name__) -RMQ_ARGS = { - "durable": False, - "arguments": {"x-message-ttl": 5000}, - "auto_delete": True -} - -CODE_TEMPLATE = """ -venv_file = "/snekbox/.venv/bin/activate_this.py" -exec(open(venv_file).read(), dict(__file__=venv_file)) - -try: -{CODE} -except: - import traceback - print(traceback.format_exc()) -""" - ESCAPE_REGEX = re.compile("[`\u202E\u200B]{3,}") FORMATTED_CODE_REGEX = re.compile( r"^\s*" # any leading whitespace from the beginning of the string @@ -53,42 +37,47 @@ RAW_CODE_REGEX = re.compile( re.DOTALL # "." also matches newlines ) +MAX_PASTE_LEN = 1000 + class Snekbox: """ - Safe evaluation using Snekbox + Safe evaluation of Python code using Snekbox """ def __init__(self, bot: Bot): self.bot = bot self.jobs = {} - @property - def rmq(self) -> RMQ: - return self.bot.get_cog("RMQ") - - @command(name='eval', aliases=('e',)) - @guild_only() - @in_channel(Channels.bot, bypass_roles=STAFF_ROLES) - async def eval_command(self, ctx: Context, *, code: str = None): - """ - Run some code. get the result back. We've done our best to make this safe, but do let us know if you - manage to find an issue with it! - - This command supports multiple lines of code, including code wrapped inside a formatted code block. - """ + async def post_eval(self, code: str) -> dict: + """Send a POST request to the Snekbox API to evaluate code and return the results.""" + url = URLs.snekbox_eval_api + data = {"input": code} + async with self.bot.http_session.post(url, json=data, raise_for_status=True) as resp: + return await resp.json() - if ctx.author.id in self.jobs: - await ctx.send(f"{ctx.author.mention} You've already got a job running - please wait for it to finish!") - return - - if not code: # None or empty string - return await ctx.invoke(self.bot.get_command("help"), "eval") + async def upload_output(self, output: str) -> Optional[str]: + """Upload the eval output to a paste service and return a URL to it if successful.""" + log.trace("Uploading full output to paste service...") - log.info(f"Received code from {ctx.author.name}#{ctx.author.discriminator} for evaluation:\n{code}") - self.jobs[ctx.author.id] = datetime.datetime.now() + if len(output) > MAX_PASTE_LEN: + log.info("Full output is too long to upload") + return "too long to upload" - # Strip whitespace and inline or block code markdown and extract the code and some formatting info + url = URLs.paste_service.format(key="documents") + try: + async with self.bot.http_session.post(url, data=output, raise_for_status=True) as resp: + data = await resp.json() + + if "key" in data: + return URLs.paste_service.format(key=data["key"]) + except Exception: + # 400 (Bad Request) means there are too many characters + log.exception("Failed to upload full output to paste service!") + + @staticmethod + def prepare_input(code: str) -> str: + """Extract code from the Markdown, format it, and insert it into the code template.""" match = FORMATTED_CODE_REGEX.fullmatch(code) if match: code, block, lang, delim = match.group("code", "block", "lang", "delim") @@ -100,87 +89,138 @@ class Snekbox: log.trace(f"Extracted {info} for evaluation:\n{code}") else: code = textwrap.dedent(RAW_CODE_REGEX.fullmatch(code).group("code")) - log.trace(f"Eval message contains not or badly formatted code, stripping whitespace only:\n{code}") + log.trace( + f"Eval message contains unformatted or badly formatted code, " + f"stripping whitespace only:\n{code}" + ) - try: - stripped_lines = [ln.strip() for ln in code.split('\n')] - if all(line.startswith('#') for line in stripped_lines): - return await ctx.send( - f"{ctx.author.mention} Your eval job has completed.\n\n```[No output]```" - ) + return code + + @staticmethod + def get_results_message(results: dict) -> Tuple[str, str]: + """Return a user-friendly message and error corresponding to the process's return code.""" + stdout, returncode = results["stdout"], results["returncode"] + msg = f"Your eval job has completed with return code {returncode}" + error = "" + + if returncode is None: + msg = "Your eval job has failed" + error = stdout.strip() + elif returncode == 128 + Signals.SIGKILL: + msg = "Your eval job timed out or ran out of memory" + elif returncode == 255: + msg = "Your eval job has failed" + error = "A fatal NsJail error occurred" + else: + # Try to append signal's name if one exists + try: + name = Signals(returncode - 128).name + msg = f"{msg} ({name})" + except ValueError: + pass + + return msg, error + + async def format_output(self, output: str) -> Tuple[str, Optional[str]]: + """ + Format the output and return a tuple of the formatted output and a URL to the full output. + + Prepend each line with a line number. Truncate if there are over 10 lines or 1000 characters + and upload the full output to a paste service. + """ + log.trace("Formatting output...") + + output = output.strip(" \n") + original_output = output # To be uploaded to a pasting service if needed + paste_link = None + + if "<@" in output: + output = output.replace("<@", "<@\u200B") # Zero-width space - code = textwrap.indent(code, " ") - code = CODE_TEMPLATE.replace("{CODE}", code) + if "<!@" in output: + output = output.replace("<!@", "<!@\u200B") # Zero-width space - await self.rmq.send_json( - "input", - snekid=str(ctx.author.id), message=code + if ESCAPE_REGEX.findall(output): + return "Code block escape attempt detected; will not output result", paste_link + + truncated = False + lines = output.count("\n") + + if lines > 0: + output = output.split("\n")[:10] # Only first 10 cause the rest is truncated anyway + output = (f"{i:03d} | {line}" for i, line in enumerate(output, 1)) + output = "\n".join(output) + + if lines > 10: + truncated = True + if len(output) >= 1000: + output = f"{output[:1000]}\n... (truncated - too long, too many lines)" + else: + output = f"{output}\n... (truncated - too many lines)" + elif len(output) >= 1000: + truncated = True + output = f"{output[:1000]}\n... (truncated - too long)" + + if truncated: + paste_link = await self.upload_output(original_output) + + output = output.strip() + if not output: + output = "[No output]" + + return output, paste_link + + @command(name="eval", aliases=("e",)) + @guild_only() + @in_channel(Channels.bot, bypass_roles=STAFF_ROLES) + async def eval_command(self, ctx: Context, *, code: str = None): + """ + Run Python code and get the results. + + This command supports multiple lines of code, including code wrapped inside a formatted code + block. We've done our best to make this safe, but do let us know if you manage to find an + issue with it! + """ + if ctx.author.id in self.jobs: + return await ctx.send( + f"{ctx.author.mention} You've already got a job running - " + f"please wait for it to finish!" ) - async with ctx.typing(): - message = await self.rmq.consume(str(ctx.author.id), **RMQ_ARGS) - paste_link = None + if not code: # None or empty string + return await ctx.invoke(self.bot.get_command("help"), "eval") - if isinstance(message, str): - output = str.strip(" \n") - else: - output = message.body.decode().strip(" \n") + log.info( + f"Received code from {ctx.author.name}#{ctx.author.discriminator} " + f"for evaluation:\n{code}" + ) - if "<@" in output: - output = output.replace("<@", "<@\u200B") # Zero-width space + self.jobs[ctx.author.id] = datetime.datetime.now() + code = self.prepare_input(code) - if "<!@" in output: - output = output.replace("<!@", "<!@\u200B") # Zero-width space + try: + async with ctx.typing(): + results = await self.post_eval(code) + msg, error = self.get_results_message(results) - if ESCAPE_REGEX.findall(output): - output = "Code block escape attempt detected; will not output result" + if error: + output, paste_link = error, None else: - # the original output, to send to a pasting service if needed - full_output = output - truncated = False - if output.count("\n") > 0: - output = [f"{i:03d} | {line}" for i, line in enumerate(output.split("\n"), start=1)] - output = "\n".join(output) - - if output.count("\n") > 10: - output = "\n".join(output.split("\n")[:10]) - - if len(output) >= 1000: - output = f"{output[:1000]}\n... (truncated - too long, too many lines)" - else: - output = f"{output}\n... (truncated - too many lines)" - truncated = True - - elif len(output) >= 1000: - output = f"{output[:1000]}\n... (truncated - too long)" - truncated = True - - if truncated: - try: - response = await self.bot.http_session.post( - URLs.paste_service.format(key="documents"), - data=full_output - ) - data = await response.json() - if "key" in data: - paste_link = URLs.paste_service.format(key=data["key"]) - except Exception: - log.exception("Failed to upload full output to paste service!") - - if output.strip(): - if paste_link: - msg = f"{ctx.author.mention} Your eval job has completed.\n\n```py\n{output}\n```" \ - f"\nFull output: {paste_link}" - else: - msg = f"{ctx.author.mention} Your eval job has completed.\n\n```py\n{output}\n```" - - response = await ctx.send(msg) - self.bot.loop.create_task(wait_for_deletion(response, user_ids=(ctx.author.id,), client=ctx.bot)) + output, paste_link = await self.format_output(results["stdout"]) - else: - await ctx.send( - f"{ctx.author.mention} Your eval job has completed.\n\n```[No output]```" - ) + msg = f"{ctx.author.mention} {msg}.\n\n```py\n{output}\n```" + if paste_link: + msg = f"{msg}\nFull output: {paste_link}" + + response = await ctx.send(msg) + self.bot.loop.create_task( + wait_for_deletion(response, user_ids=(ctx.author.id,), client=ctx.bot) + ) + + log.info( + f"{ctx.author.name}#{ctx.author.discriminator}'s job had a return code of " + f"{results['returncode']}" + ) finally: del self.jobs[ctx.author.id] diff --git a/bot/cogs/superstarify.py b/bot/cogs/superstarify/__init__.py index f46f62552..4b26f3f40 100644 --- a/bot/cogs/superstarify.py +++ b/bot/cogs/superstarify/__init__.py @@ -1,5 +1,6 @@ import logging import random +from datetime import datetime from discord import Colour, Embed, Member from discord.errors import Forbidden @@ -7,12 +8,11 @@ from discord.ext.commands import Bot, Context, command from bot.cogs.moderation import Moderation from bot.cogs.modlog import ModLog -from bot.constants import ( - Icons, Keys, - MODERATION_ROLES, NEGATIVE_REPLIES, - POSITIVE_REPLIES, URLs -) +from bot.cogs.superstarify.stars import get_nick +from bot.constants import Icons, MODERATION_ROLES, POSITIVE_REPLIES +from bot.converters import ExpirationDate from bot.decorators import with_role +from bot.utils.moderation import post_infraction log = logging.getLogger(__name__) NICKNAME_POLICY_URL = "https://pythondiscord.com/about/rules#nickname-policy" @@ -25,7 +25,6 @@ class Superstarify: def __init__(self, bot: Bot): self.bot = bot - self.headers = {"X-API-KEY": Keys.site_api} @property def moderation(self) -> Moderation: @@ -46,35 +45,43 @@ class Superstarify: if before.display_name == after.display_name: return # User didn't change their nickname. Abort! - log.debug( + log.trace( f"{before.display_name} is trying to change their nickname to {after.display_name}. " "Checking if the user is in superstar-prison..." ) - response = await self.bot.http_session.get( - URLs.site_superstarify_api, - headers=self.headers, - params={"user_id": str(before.id)} + active_superstarifies = await self.bot.api_client.get( + 'bot/infractions', + params={ + 'active': 'true', + 'type': 'superstar', + 'user__id': str(before.id) + } ) - response = await response.json() - - if response and response.get("end_timestamp") and not response.get("error_code"): - if after.display_name == response.get("forced_nick"): + if active_superstarifies: + [infraction] = active_superstarifies + forced_nick = get_nick(infraction['id'], before.id) + if after.display_name == forced_nick: return # Nick change was triggered by this event. Ignore. - log.debug( + log.info( f"{after.display_name} is currently in superstar-prison. " f"Changing the nick back to {before.display_name}." ) - await after.edit(nick=response.get("forced_nick")) + await after.edit(nick=forced_nick) + end_timestamp_human = ( + datetime.fromisoformat(infraction['expires_at'][:-1]) + .strftime('%c') + ) + try: await after.send( "You have tried to change your nickname on the **Python Discord** server " f"from **{before.display_name}** to **{after.display_name}**, but as you " "are currently in superstar-prison, you do not have permission to do so. " "You will be allowed to change your nickname again at the following time:\n\n" - f"**{response.get('end_timestamp')}**." + f"**{end_timestamp_human}**." ) except Forbidden: log.warning( @@ -92,23 +99,23 @@ class Superstarify: back to the forced nickname. """ - response = await self.bot.http_session.get( - URLs.site_superstarify_api, - headers=self.headers, - params={"user_id": str(member.id)} + active_superstarifies = await self.bot.api_client.get( + 'bot/infractions', + params={ + 'active': 'true', + 'type': 'superstarify', + 'user__id': member.id + } ) - response = await response.json() - - if response and response.get("end_timestamp") and not response.get("error_code"): - forced_nick = response.get("forced_nick") - end_timestamp = response.get("end_timestamp") - log.debug( - f"{member.name} rejoined but is currently in superstar-prison. " - f"Changing the nick back to {forced_nick}." + if active_superstarifies: + [infraction] = active_superstarifies + forced_nick = get_nick(infraction['id'], member.id) + await member.edit(nick=forced_nick) + end_timestamp_human = ( + datetime.fromisoformat(infraction['expires_at'][:-1]).strftime('%c') ) - await member.edit(nick=forced_nick) try: await member.send( "You have left and rejoined the **Python Discord** server, effectively resetting " @@ -116,7 +123,7 @@ class Superstarify: "but as you are currently in superstar-prison, you do not have permission to do so. " "Therefore your nickname was automatically changed back. You will be allowed to " "change your nickname again at the following time:\n\n" - f"**{end_timestamp}**." + f"**{end_timestamp_human}**." ) except Forbidden: log.warning( @@ -132,7 +139,7 @@ class Superstarify: f"**{member.name}#{member.discriminator}** (`{member.id}`)\n\n" f"Superstarified member potentially tried to escape the prison.\n" f"Restored enforced nickname: `{forced_nick}`\n" - f"Superstardom ends: **{end_timestamp}**" + f"Superstardom ends: **{end_timestamp_human}**" ) await self.modlog.send_log_message( icon_url=Icons.user_update, @@ -144,95 +151,74 @@ class Superstarify: @command(name='superstarify', aliases=('force_nick', 'star')) @with_role(*MODERATION_ROLES) - async def superstarify(self, ctx: Context, member: Member, duration: str, *, forced_nick: str = None): + async def superstarify( + self, ctx: Context, member: Member, expiration: ExpirationDate, reason: str = None + ): """ This command will force a random superstar name (like Taylor Swift) to be the user's - nickname for a specified duration. If a forced_nick is provided, it will use that instead. - - :param ctx: Discord message context - :param ta: - If provided, this function shows data for that specific tag. - If not provided, this function shows the caller a list of all tags. + nickname for a specified duration. An optional reason can be provided. + If no reason is given, the original name will be shown in a generated reason. """ - log.debug( - f"Attempting to superstarify {member.display_name} for {duration}. " - f"forced_nick is set to {forced_nick}." + active_superstarifies = await self.bot.api_client.get( + 'bot/infractions', + params={ + 'active': 'true', + 'type': 'superstar', + 'user__id': str(member.id) + } ) + if active_superstarifies: + return await ctx.send( + ":x: According to my records, this user is already superstarified. " + f"See infraction **#{active_superstarifies[0]['id']}**." + ) - embed = Embed() - embed.colour = Colour.blurple() - - params = { - "user_id": str(member.id), - "duration": duration - } - - if forced_nick: - params["forced_nick"] = forced_nick - - response = await self.bot.http_session.post( - URLs.site_superstarify_api, - headers=self.headers, - json=params + infraction = await post_infraction( + ctx, member, + type='superstar', reason=reason or ('old nick: ' + member.display_name), + expires_at=expiration ) + forced_nick = get_nick(infraction['id'], member.id) - response = await response.json() - - if "error_message" in response: - log.warning( - "Encountered the following error when trying to superstarify the user:\n" - f"{response.get('error_message')}" - ) - embed.colour = Colour.red() - embed.title = random.choice(NEGATIVE_REPLIES) - embed.description = response.get("error_message") - return await ctx.send(embed=embed) - - else: - forced_nick = response.get('forced_nick') - end_time = response.get("end_timestamp") - image_url = response.get("image_url") - old_nick = member.display_name - - embed.title = "Congratulations!" - embed.description = ( - f"Your previous nickname, **{old_nick}**, was so bad that we have decided to change it. " - f"Your new nickname will be **{forced_nick}**.\n\n" - f"You will be unable to change your nickname until \n**{end_time}**.\n\n" - "If you're confused by this, please read our " - f"[official nickname policy]({NICKNAME_POLICY_URL})." - ) - embed.set_image(url=image_url) + embed = Embed() + embed.title = "Congratulations!" + embed.description = ( + f"Your previous nickname, **{member.display_name}**, was so bad that we have decided to change it. " + f"Your new nickname will be **{forced_nick}**.\n\n" + f"You will be unable to change your nickname until \n**{expiration}**.\n\n" + "If you're confused by this, please read our " + f"[official nickname policy]({NICKNAME_POLICY_URL})." + ) - # Log to the mod_log channel - log.trace("Logging to the #mod-log channel. This could fail because of channel permissions.") - mod_log_message = ( - f"**{member.name}#{member.discriminator}** (`{member.id}`)\n\n" - f"Superstarified by **{ctx.author.name}**\n" - f"Old nickname: `{old_nick}`\n" - f"New nickname: `{forced_nick}`\n" - f"Superstardom ends: **{end_time}**" - ) - await self.modlog.send_log_message( - icon_url=Icons.user_update, - colour=Colour.gold(), - title="Member Achieved Superstardom", - text=mod_log_message, - thumbnail=member.avatar_url_as(static_format="png") - ) + # Log to the mod_log channel + log.trace("Logging to the #mod-log channel. This could fail because of channel permissions.") + mod_log_message = ( + f"**{member.name}#{member.discriminator}** (`{member.id}`)\n\n" + f"Superstarified by **{ctx.author.name}**\n" + f"Old nickname: `{member.display_name}`\n" + f"New nickname: `{forced_nick}`\n" + f"Superstardom ends: **{expiration}**" + ) + await self.modlog.send_log_message( + icon_url=Icons.user_update, + colour=Colour.gold(), + title="Member Achieved Superstardom", + text=mod_log_message, + thumbnail=member.avatar_url_as(static_format="png") + ) - await self.moderation.notify_infraction( - user=member, - infr_type="Superstarify", - duration=duration, - reason=f"Your nickname didn't comply with our [nickname policy]({NICKNAME_POLICY_URL})." - ) + await self.moderation.notify_infraction( + user=member, + infr_type="Superstarify", + expires_at=expiration, + reason=f"Your nickname didn't comply with our [nickname policy]({NICKNAME_POLICY_URL})." + ) - # Change the nick and return the embed - log.debug("Changing the users nickname and sending the embed.") - await member.edit(nick=forced_nick) - await ctx.send(embed=embed) + # Change the nick and return the embed + log.trace("Changing the users nickname and sending the embed.") + await member.edit(nick=forced_nick) + await ctx.send(embed=embed) @command(name='unsuperstarify', aliases=('release_nick', 'unstar')) @with_role(*MODERATION_ROLES) @@ -250,33 +236,35 @@ class Superstarify: embed = Embed() embed.colour = Colour.blurple() - response = await self.bot.http_session.delete( - URLs.site_superstarify_api, - headers=self.headers, - json={"user_id": str(member.id)} + active_superstarifies = await self.bot.api_client.get( + 'bot/infractions', + params={ + 'active': 'true', + 'type': 'superstar', + 'user__id': str(member.id) + } + ) + if not active_superstarifies: + return await ctx.send( + ":x: There is no active superstarify infraction for this user." + ) + + [infraction] = active_superstarifies + await self.bot.api_client.patch( + 'bot/infractions/' + str(infraction['id']), + json={'active': False} ) - response = await response.json() + embed = Embed() embed.description = "User has been released from superstar-prison." embed.title = random.choice(POSITIVE_REPLIES) - if "error_message" in response: - embed.colour = Colour.red() - embed.title = random.choice(NEGATIVE_REPLIES) - embed.description = response.get("error_message") - log.warning( - f"Error encountered when trying to unsuperstarify {member.display_name}:\n" - f"{response}" - ) - - else: - await self.moderation.notify_pardon( - user=member, - title="You are no longer superstarified.", - content="You may now change your nickname on the server." - ) - - log.debug(f"{member.display_name} was successfully released from superstar-prison.") + await self.moderation.notify_pardon( + user=member, + title="You are no longer superstarified.", + content="You may now change your nickname on the server." + ) + log.trace(f"{member.display_name} was successfully released from superstar-prison.") await ctx.send(embed=embed) diff --git a/bot/cogs/superstarify/stars.py b/bot/cogs/superstarify/stars.py new file mode 100644 index 000000000..9b49d7175 --- /dev/null +++ b/bot/cogs/superstarify/stars.py @@ -0,0 +1,86 @@ +import random + + +STAR_NAMES = ( + "Adele", + "Aerosmith", + "Aretha Franklin", + "Ayumi Hamasaki", + "B'z", + "Barbra Streisand", + "Barry Manilow", + "Barry White", + "Beyonce", + "Billy Joel", + "Bob Dylan", + "Bob Marley", + "Bob Seger", + "Bon Jovi", + "Britney Spears", + "Bruce Springsteen", + "Bruno Mars", + "Bryan Adams", + "Celine Dion", + "Cher", + "Christina Aguilera", + "David Bowie", + "Donna Summer", + "Drake", + "Ed Sheeran", + "Elton John", + "Elvis Presley", + "Eminem", + "Enya", + "Flo Rida", + "Frank Sinatra", + "Garth Brooks", + "George Michael", + "George Strait", + "James Taylor", + "Janet Jackson", + "Jay-Z", + "Johnny Cash", + "Johnny Hallyday", + "Julio Iglesias", + "Justin Bieber", + "Justin Timberlake", + "Kanye West", + "Katy Perry", + "Kenny G", + "Kenny Rogers", + "Lady Gaga", + "Lil Wayne", + "Linda Ronstadt", + "Lionel Richie", + "Madonna", + "Mariah Carey", + "Meat Loaf", + "Michael Jackson", + "Neil Diamond", + "Nicki Minaj", + "Olivia Newton-John", + "Paul McCartney", + "Phil Collins", + "Pink", + "Prince", + "Reba McEntire", + "Rihanna", + "Robbie Williams", + "Rod Stewart", + "Santana", + "Shania Twain", + "Stevie Wonder", + "Taylor Swift", + "Tim McGraw", + "Tina Turner", + "Tom Petty", + "Tupac Shakur", + "Usher", + "Van Halen", + "Whitney Houston", +) + + +def get_nick(infraction_id, member_id): + rng = random.Random(str(infraction_id) + str(member_id)) + return rng.choice(STAR_NAMES) diff --git a/bot/cogs/sync/__init__.py b/bot/cogs/sync/__init__.py new file mode 100644 index 000000000..e4f960620 --- /dev/null +++ b/bot/cogs/sync/__init__.py @@ -0,0 +1,10 @@ +import logging + +from .cog import Sync + +log = logging.getLogger(__name__) + + +def setup(bot): + bot.add_cog(Sync(bot)) + log.info("Cog loaded: Sync") diff --git a/bot/cogs/sync/cog.py b/bot/cogs/sync/cog.py new file mode 100644 index 000000000..222c1668b --- /dev/null +++ b/bot/cogs/sync/cog.py @@ -0,0 +1,180 @@ +import logging +from typing import Callable, Iterable + +from discord import Guild, Member, Role +from discord.ext import commands +from discord.ext.commands import Bot + +from bot import constants +from bot.api import ResponseCodeError +from bot.cogs.sync import syncers + +log = logging.getLogger(__name__) + + +class Sync: + """Captures relevant events and sends them to the site.""" + + # The server to synchronize events on. + # Note that setting this wrongly will result in things getting deleted + # that possibly shouldn't be. + SYNC_SERVER_ID = constants.Guild.id + + # An iterable of callables that are called when the bot is ready. + ON_READY_SYNCERS: Iterable[Callable[[Bot, Guild], None]] = ( + syncers.sync_roles, + syncers.sync_users + ) + + def __init__(self, bot): + self.bot = bot + + async def on_ready(self): + guild = self.bot.get_guild(self.SYNC_SERVER_ID) + if guild is not None: + for syncer in self.ON_READY_SYNCERS: + syncer_name = syncer.__name__[5:] # drop off `sync_` + log.info("Starting `%s` syncer.", syncer_name) + total_created, total_updated = await syncer(self.bot, guild) + log.info( + "`%s` syncer finished, created `%d`, updated `%d`.", + syncer_name, total_created, total_updated + ) + + async def on_guild_role_create(self, role: Role): + await self.bot.api_client.post( + 'bot/roles', + json={ + 'colour': role.colour.value, + 'id': role.id, + 'name': role.name, + 'permissions': role.permissions.value + } + ) + + async def on_guild_role_delete(self, role: Role): + log.warning( + ( + "Attempted to delete role `%s` (`%d`), but role deletion " + "is currently not implementeed." + ), + role.name, role.id + ) + + async def on_guild_role_update(self, before: Role, after: Role): + if ( + before.name != after.name + or before.colour != after.colour + or before.permissions != after.permissions + ): + await self.bot.api_client.put( + 'bot/roles/' + str(after.id), + json={ + 'colour': after.colour.value, + 'id': after.id, + 'name': after.name, + 'permissions': after.permissions.value + } + ) + + async def on_member_join(self, member: Member): + packed = { + 'avatar_hash': member.avatar, + 'discriminator': int(member.discriminator), + 'id': member.id, + 'in_guild': True, + 'name': member.name, + 'roles': sorted(role.id for role in member.roles) + } + + got_error = False + + try: + # First try an update of the user to set the `in_guild` field and other + # fields that may have changed since the last time we've seen them. + await self.bot.api_client.put('bot/users/' + str(member.id), json=packed) + + except ResponseCodeError as e: + # If we didn't get 404, something else broke - propagate it up. + if e.response.status != 404: + raise + + got_error = True # yikes + + if got_error: + # If we got `404`, the user is new. Create them. + await self.bot.api_client.post('bot/users', json=packed) + + async def on_member_leave(self, member: Member): + await self.bot.api_client.put( + 'bot/users/' + str(member.id), + json={ + 'avatar_hash': member.avatar, + 'discriminator': int(member.discriminator), + 'id': member.id, + 'in_guild': True, + 'name': member.name, + 'roles': sorted(role.id for role in member.roles) + } + ) + + async def on_member_update(self, before: Member, after: Member): + if ( + before.name != after.name + or before.avatar != after.avatar + or before.discriminator != after.discriminator + or before.roles != after.roles + ): + try: + await self.bot.api_client.put( + 'bot/users/' + str(after.id), + json={ + 'avatar_hash': after.avatar, + 'discriminator': int(after.discriminator), + 'id': after.id, + 'in_guild': True, + 'name': after.name, + 'roles': sorted(role.id for role in after.roles) + } + ) + except ResponseCodeError as e: + if e.response.status != 404: + raise + + log.warning( + "Unable to update user, got 404. " + "Assuming race condition from join event." + ) + + @commands.group(name='sync') + @commands.has_permissions(administrator=True) + async def sync_group(self, ctx): + """Run synchronizations between the bot and site manually.""" + + @sync_group.command(name='roles') + @commands.has_permissions(administrator=True) + async def sync_roles_command(self, ctx): + """Manually synchronize the guild's roles with the roles on the site.""" + + initial_response = await ctx.send("📊 Synchronizing roles.") + total_created, total_updated = await syncers.sync_roles(self.bot, ctx.guild) + await initial_response.edit( + content=( + f"👌 Role synchronization complete, created **{total_created}** " + f"and updated **{total_created}** roles." + ) + ) + + @sync_group.command(name='users') + @commands.has_permissions(administrator=True) + async def sync_users_command(self, ctx): + """Manually synchronize the guild's users with the users on the site.""" + + initial_response = await ctx.send("📊 Synchronizing users.") + total_created, total_updated = await syncers.sync_users(self.bot, ctx.guild) + await initial_response.edit( + content=( + f"👌 User synchronization complete, created **{total_created}** " + f"and updated **{total_created}** users." + ) + ) diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py new file mode 100644 index 000000000..3037d2e31 --- /dev/null +++ b/bot/cogs/sync/syncers.py @@ -0,0 +1,227 @@ +from collections import namedtuple +from typing import Dict, Set, Tuple + +from discord import Guild +from discord.ext.commands import Bot + +# These objects are declared as namedtuples because tuples are hashable, +# something that we make use of when diffing site roles against guild roles. +Role = namedtuple('Role', ('id', 'name', 'colour', 'permissions')) +User = namedtuple('User', ('id', 'name', 'discriminator', 'avatar_hash', 'roles', 'in_guild')) + + +def get_roles_for_sync( + guild_roles: Set[Role], api_roles: Set[Role] +) -> Tuple[Set[Role], Set[Role]]: + """ + Determine which roles should be created or updated on the site. + + Arguments: + guild_roles (Set[Role]): + Roles that were found on the guild at startup. + + api_roles (Set[Role]): + Roles that were retrieved from the API at startup. + + Returns: + Tuple[Set[Role], Set[Role]]: + A tuple with two elements. The first element represents + roles to be created on the site, meaning that they were + present on the cached guild but not on the API. The second + element represents roles to be updated, meaning they were + present on both the cached guild and the API but non-ID + fields have changed inbetween. + """ + + guild_role_ids = {role.id for role in guild_roles} + api_role_ids = {role.id for role in api_roles} + new_role_ids = guild_role_ids - api_role_ids + + # New roles are those which are on the cached guild but not on the + # API guild, going by the role ID. We need to send them in for creation. + roles_to_create = {role for role in guild_roles if role.id in new_role_ids} + roles_to_update = guild_roles - api_roles - roles_to_create + return roles_to_create, roles_to_update + + +async def sync_roles(bot: Bot, guild: Guild): + """ + Synchronize roles found on the given `guild` with the ones on the API. + + Arguments: + bot (discord.ext.commands.Bot): + The bot instance that we're running with. + + guild (discord.Guild): + The guild instance from the bot's cache + to synchronize roles with. + + Returns: + Tuple[int, int]: + A tuple with two integers representing how many roles were created + (element `0`) and how many roles were updated (element `1`). + """ + + roles = await bot.api_client.get('bot/roles') + + # Pack API roles and guild roles into one common format, + # which is also hashable. We need hashability to be able + # to compare these easily later using sets. + api_roles = {Role(**role_dict) for role_dict in roles} + guild_roles = { + Role( + id=role.id, name=role.name, + colour=role.colour.value, permissions=role.permissions.value + ) + for role in guild.roles + } + roles_to_create, roles_to_update = get_roles_for_sync(guild_roles, api_roles) + + for role in roles_to_create: + await bot.api_client.post( + 'bot/roles', + json={ + 'id': role.id, + 'name': role.name, + 'colour': role.colour, + 'permissions': role.permissions + } + ) + + for role in roles_to_update: + await bot.api_client.put( + 'bot/roles/' + str(role.id), + json={ + 'id': role.id, + 'name': role.name, + 'colour': role.colour, + 'permissions': role.permissions + } + ) + + return (len(roles_to_create), len(roles_to_update)) + + +def get_users_for_sync( + guild_users: Dict[int, User], api_users: Dict[int, User] +) -> Tuple[Set[User], Set[User]]: + """ + Determine which users should be created or updated on the website. + + Arguments: + guild_users (Dict[int, User]): + A mapping of user IDs to user data, populated from the + guild cached on the running bot instance. + + api_users (Dict[int, User]): + A mapping of user IDs to user data, populated from the API's + current inventory of all users. + + Returns: + Tuple[Set[User], Set[User]]: + Two user sets as a tuple. The first element represents users + to be created on the website, these are users that are present + in the cached guild data but not in the API at all, going by + their ID. The second element represents users to update. It is + populated by users which are present on both the API and the + guild, but where the attribute of a user on the API is not + equal to the attribute of the user on the guild. + """ + + users_to_create = set() + users_to_update = set() + + for api_user in api_users.values(): + guild_user = guild_users.get(api_user.id) + if guild_user is not None: + if api_user != guild_user: + users_to_update.add(guild_user) + + elif api_user.in_guild: + # The user is known on the API but not the guild, and the + # API currently specifies that the user is a member of the guild. + # This means that the user has left since the last sync. + # Update the `in_guild` attribute of the user on the site + # to signify that the user left. + new_api_user = api_user._replace(in_guild=False) + users_to_update.add(new_api_user) + + new_user_ids = set(guild_users.keys()) - set(api_users.keys()) + for user_id in new_user_ids: + # The user is known on the guild but not on the API. This means + # that the user has joined since the last sync. Create it. + new_user = guild_users[user_id] + users_to_create.add(new_user) + + return users_to_create, users_to_update + + +async def sync_users(bot: Bot, guild: Guild): + """ + Synchronize users found on the given + `guild` with the ones on the API. + + Arguments: + bot (discord.ext.commands.Bot): + The bot instance that we're running with. + + guild (discord.Guild): + The guild instance from the bot's cache + to synchronize roles with. + + Returns: + Tuple[int, int]: + A tuple with two integers representing how many users were created + (element `0`) and how many users were updated (element `1`). + """ + + current_users = await bot.api_client.get('bot/users') + + # Pack API users and guild users into one common format, + # which is also hashable. We need hashability to be able + # to compare these easily later using sets. + api_users = { + user_dict['id']: User( + roles=tuple(sorted(user_dict.pop('roles'))), + **user_dict + ) + for user_dict in current_users + } + guild_users = { + member.id: User( + id=member.id, name=member.name, + discriminator=int(member.discriminator), avatar_hash=member.avatar, + roles=tuple(sorted(role.id for role in member.roles)), in_guild=True + ) + for member in guild.members + } + + users_to_create, users_to_update = get_users_for_sync(guild_users, api_users) + + for user in users_to_create: + await bot.api_client.post( + 'bot/users', + json={ + 'avatar_hash': user.avatar_hash, + 'discriminator': user.discriminator, + 'id': user.id, + 'in_guild': user.in_guild, + 'name': user.name, + 'roles': list(user.roles) + } + ) + + for user in users_to_update: + await bot.api_client.put( + 'bot/users/' + str(user.id), + json={ + 'avatar_hash': user.avatar_hash, + 'discriminator': user.discriminator, + 'id': user.id, + 'in_guild': user.in_guild, + 'name': user.name, + 'roles': list(user.roles) + } + ) + + return (len(users_to_create), len(users_to_update)) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index d6957e360..7b1003148 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -1,19 +1,11 @@ import logging -import random import time -from typing import Optional from discord import Colour, Embed -from discord.ext.commands import ( - BadArgument, Bot, - Context, group -) +from discord.ext.commands import Bot, Context, group -from bot.constants import ( - Channels, Cooldowns, ERROR_REPLIES, Keys, - MODERATION_ROLES, Roles, URLs -) -from bot.converters import TagContentConverter, TagNameConverter, ValidURL +from bot.constants import Channels, Cooldowns, Keys, MODERATION_ROLES, Roles +from bot.converters import TagContentConverter, TagNameConverter from bot.decorators import with_role from bot.pagination import LinePaginator @@ -35,72 +27,7 @@ class Tags: def __init__(self, bot: Bot): self.bot = bot self.tag_cooldowns = {} - self.headers = {"X-API-KEY": Keys.site_api} - - async def get_tag_data(self, tag_name=None) -> dict: - """ - Retrieve the tag_data from our API - - :param tag_name: the tag to retrieve - :return: - if tag_name was provided, returns a dict with tag data. - if not, returns a list of dicts with all tag data. - - """ - params = {} - - if tag_name: - params["tag_name"] = tag_name - - response = await self.bot.http_session.get(URLs.site_tags_api, headers=self.headers, params=params) - tag_data = await response.json() - - return tag_data - - async def post_tag_data(self, tag_name: str, tag_content: str, image_url: Optional[str]) -> dict: - """ - Send some tag_data to our API to have it saved in the database. - - :param tag_name: The name of the tag to create or edit. - :param tag_content: The content of the tag. - :param image_url: The image URL of the tag, can be `None`. - :return: json response from the API in the following format: - { - 'success': bool - } - """ - - params = { - 'tag_name': tag_name, - 'tag_content': tag_content, - 'image_url': image_url - } - - response = await self.bot.http_session.post(URLs.site_tags_api, headers=self.headers, json=params) - tag_data = await response.json() - - return tag_data - - async def delete_tag_data(self, tag_name: str) -> dict: - """ - Delete a tag using our API. - - :param tag_name: The name of the tag to delete. - :return: json response from the API in the following format: - { - 'success': bool - } - """ - - params = {} - - if tag_name: - params['tag_name'] = tag_name - - response = await self.bot.http_session.delete(URLs.site_tags_api, headers=self.headers, json=params) - tag_data = await response.json() - - return tag_data + self.headers = {"Authorization": f"Token {Keys.site_api}"} @group(name='tags', aliases=('tag', 't'), hidden=True, invoke_without_command=True) async def tags_group(self, ctx: Context, *, tag_name: TagNameConverter = None): @@ -148,69 +75,32 @@ class Tags: f"Cooldown ends in {time_left:.1f} seconds.") return - tags = [] - - embed: Embed = Embed() - embed.colour = Colour.red() - tag_data = await self.get_tag_data(tag_name) - - # If we found something, prepare that data - if tag_data: - embed.colour = Colour.blurple() - - if tag_name: - log.debug(f"{ctx.author} requested the tag '{tag_name}'") - embed.title = tag_name - - if ctx.channel.id not in TEST_CHANNELS: - self.tag_cooldowns[tag_name] = { - "time": time.time(), - "channel": ctx.channel.id - } - - else: - embed.title = "**Current tags**" - - if isinstance(tag_data, list): - log.debug(f"{ctx.author} requested a list of all tags") - tags = [f"**»** {tag['tag_name']}" for tag in tag_data] - tags = sorted(tags) - - else: - embed.description = tag_data['tag_content'] - if tag_data['image_url'] is not None: - embed.set_image(url=tag_data['image_url']) + if 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_data(tag['embed'])) - # If its invoked from error handler just ignore it. - elif hasattr(ctx, "invoked_from_error_handler"): - return - # If not, prepare an error message. else: - embed.colour = Colour.red() - - if isinstance(tag_data, dict): - log.warning(f"{ctx.author} requested the tag '{tag_name}', but it could not be found.") - embed.description = f"**{tag_name}** is an unknown tag name. Please check the spelling and try again." + tags = await self.bot.api_client.get('bot/tags') + if not tags: + await ctx.send(embed=Embed( + description="**There are no tags in the database!**", + colour=Colour.red() + )) else: - log.warning(f"{ctx.author} requested a list of all tags, but the tags database was empty!") - embed.description = "**There are no tags in the database!**" - - if tag_name: - embed.set_footer(text="To show a list of all tags, use !tags.") - embed.title = "Tag not found." - - # Paginate if this is a list of all tags - if tags: - log.debug(f"Returning a paginated list of all tags.") - return await LinePaginator.paginate( - (lines for lines in tags), - ctx, embed, - footer_text="To show a tag, type !tags <tagname>.", - empty=False, - max_lines=15 - ) - - return await ctx.send(embed=embed) + embed: Embed = Embed(title="**Current tags**") + await LinePaginator.paginate( + sorted(f"**»** {tag['title']}" for tag in tags), + ctx, + embed, + footer_text="To show a tag, type !tags <tagname>.", + empty=False, + max_lines=15 + ) @tags_group.command(name='set', aliases=('add', 'edit', 's')) @with_role(*MODERATION_ROLES) @@ -218,44 +108,36 @@ class Tags: self, ctx: Context, tag_name: TagNameConverter, + *, tag_content: TagContentConverter, - image_url: ValidURL = None ): """ - Create a new tag or edit an existing one. + Create a new tag or update an existing one. :param ctx: discord message context :param tag_name: The name of the tag to create or edit. :param tag_content: The content of the tag. - :param image_url: An optional image for the tag. """ - tag_name = tag_name.lower().strip() - tag_content = tag_content.strip() - - embed = Embed() - embed.colour = Colour.red() - tag_data = await self.post_tag_data(tag_name, tag_content, image_url) - - if tag_data.get("success"): - log.debug(f"{ctx.author} successfully added the following tag to our database: \n" - f"tag_name: {tag_name}\n" - f"tag_content: '{tag_content}'\n" - f"image_url: '{image_url}'") - embed.colour = Colour.blurple() - embed.title = "Tag successfully added" - embed.description = f"**{tag_name}** added to tag database." - else: - log.error("There was an unexpected database error when trying to add the following tag: \n" - f"tag_name: {tag_name}\n" - f"tag_content: '{tag_content}'\n" - f"image_url: '{image_url}'\n" - f"response: {tag_data}") - embed.title = "Database error" - embed.description = ("There was a problem adding the data to the tags database. " - "Please try again. If the problem persists, see the error logs.") + body = { + 'title': tag_name.lower().strip(), + 'embed': { + 'title': tag_name, + 'description': tag_content + } + } - return await ctx.send(embed=embed) + await self.bot.api_client.post('bot/tags', json=body) + + log.debug(f"{ctx.author} successfully added the following tag to our database: \n" + f"tag_name: {tag_name}\n" + f"tag_content: '{tag_content}'\n") + + await ctx.send(embed=Embed( + title="Tag successfully added", + description=f"**{tag_name}** added to tag database.", + colour=Colour.blurple() + )) @tags_group.command(name='delete', aliases=('remove', 'rm', 'd')) @with_role(Roles.admin, Roles.owner) @@ -267,45 +149,14 @@ class Tags: :param tag_name: The name of the tag to delete. """ - tag_name = tag_name.lower().strip() - embed = Embed() - embed.colour = Colour.red() - tag_data = await self.delete_tag_data(tag_name) - - if tag_data.get("success") is True: - log.debug(f"{ctx.author} successfully deleted the tag called '{tag_name}'") - embed.colour = Colour.blurple() - embed.title = tag_name - embed.description = f"Tag successfully removed: {tag_name}." + await self.bot.api_client.delete(f'bot/tags/{tag_name}') - elif tag_data.get("success") is False: - log.debug(f"{ctx.author} tried to delete a tag called '{tag_name}', but the tag does not exist.") - embed.colour = Colour.red() - embed.title = tag_name - embed.description = "Tag doesn't appear to exist." - - else: - log.error("There was an unexpected database error when trying to delete the following tag: \n" - f"tag_name: {tag_name}\n" - f"response: {tag_data}") - embed.title = "Database error" - embed.description = ("There was an unexpected error with deleting the data from the tags database. " - "Please try again. If the problem persists, see the error logs.") - - return await ctx.send(embed=embed) - - @get_command.error - @set_command.error - @delete_command.error - async def command_error(self, ctx, error): - if isinstance(error, BadArgument): - embed = Embed() - embed.colour = Colour.red() - embed.description = str(error) - embed.title = random.choice(ERROR_REPLIES) - await ctx.send(embed=embed) - else: - log.error(f"Unhandled tag command error: {error} ({error.original})") + log.debug(f"{ctx.author} successfully deleted the tag called '{tag_name}'") + await ctx.send(embed=Embed( + title=tag_name, + description=f"Tag successfully removed: {tag_name}.", + colour=Colour.blurple() + )) def setup(bot): diff --git a/bot/cogs/watchchannels/__init__.py b/bot/cogs/watchchannels/__init__.py new file mode 100644 index 000000000..ac7713803 --- /dev/null +++ b/bot/cogs/watchchannels/__init__.py @@ -0,0 +1,15 @@ +import logging + +from .bigbrother import BigBrother +from .talentpool import TalentPool + + +log = logging.getLogger(__name__) + + +def setup(bot): + bot.add_cog(BigBrother(bot)) + log.info("Cog loaded: BigBrother") + + bot.add_cog(TalentPool(bot)) + log.info("Cog loaded: TalentPool") diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/cogs/watchchannels/bigbrother.py new file mode 100644 index 000000000..e7b3d70bc --- /dev/null +++ b/bot/cogs/watchchannels/bigbrother.py @@ -0,0 +1,100 @@ +import logging +from collections import ChainMap +from typing import Union + +from discord import User +from discord.ext.commands import Context, group + +from bot.constants import Channels, Roles, Webhooks +from bot.decorators import with_role +from bot.utils.moderation import post_infraction +from .watchchannel import WatchChannel, proxy_user + +log = logging.getLogger(__name__) + + +class BigBrother(WatchChannel): + """Monitors users by relaying their messages to a watch channel to assist with moderation.""" + + def __init__(self, bot) -> None: + super().__init__( + bot, + destination=Channels.big_brother_logs, + webhook_id=Webhooks.big_brother, + api_endpoint='bot/infractions', + api_default_params={'active': 'true', 'type': 'watch', 'ordering': '-inserted_at'}, + logger=log + ) + + @group(name='bigbrother', aliases=('bb',), invoke_without_command=True) + @with_role(Roles.owner, Roles.admin, Roles.moderator) + async def bigbrother_group(self, ctx: Context) -> None: + """Monitors users by relaying their messages to the Big Brother watch channel.""" + await ctx.invoke(self.bot.get_command("help"), "bigbrother") + + @bigbrother_group.command(name='watched', aliases=('all', 'list')) + @with_role(Roles.owner, Roles.admin, Roles.moderator) + async def watched_command(self, ctx: Context, update_cache: bool = True) -> None: + """ + Shows the users that are currently being monitored by Big Brother. + + The optional kwarg `update_cache` can be used to update the user + cache using the API before listing the users. + """ + await self.list_watched_users(ctx, update_cache) + + @bigbrother_group.command(name='watch', aliases=('w',)) + @with_role(Roles.owner, Roles.admin, Roles.moderator) + async def watch_command(self, ctx: Context, user: Union[User, proxy_user], *, reason: str) -> None: + """ + Relay messages sent by the given `user` to the `#big-brother` channel. + + A `reason` for adding the user to Big Brother is required and will be displayed + in the header when relaying messages of this user to the watchchannel. + """ + if user.bot: + await ctx.send(f":x: I'm sorry {ctx.author}, I'm afraid I can't do that. I only watch humans.") + return + + if not await self.fetch_user_cache(): + await ctx.send(f":x: Updating the user cache failed, can't watch user {user}") + return + + if user.id in self.watched_users: + await ctx.send(":x: The specified user is already being watched.") + return + + response = await post_infraction( + ctx, user, type='watch', reason=reason, hidden=True + ) + + if response is not None: + self.watched_users[user.id] = response + await ctx.send(f":white_check_mark: Messages sent by {user} will now be relayed to Big Brother.") + + @bigbrother_group.command(name='unwatch', aliases=('uw',)) + @with_role(Roles.owner, Roles.admin, Roles.moderator) + async def unwatch_command(self, ctx: Context, user: Union[User, proxy_user], *, reason: str) -> None: + """Stop relaying messages by the given `user`.""" + active_watches = await self.bot.api_client.get( + self.api_endpoint, + params=ChainMap( + self.api_default_params, + {"user__id": str(user.id)} + ) + ) + if active_watches: + [infraction] = active_watches + + await self.bot.api_client.patch( + f"{self.api_endpoint}/{infraction['id']}", + json={'active': False} + ) + + await post_infraction(ctx, user, type='watch', reason=f"Unwatched: {reason}", hidden=True, active=False) + + await ctx.send(f":white_check_mark: Messages sent by {user} will no longer be relayed.") + + self._remove_user(user.id) + else: + await ctx.send(":x: The specified user is currently not being watched.") diff --git a/bot/cogs/watchchannels/talentpool.py b/bot/cogs/watchchannels/talentpool.py new file mode 100644 index 000000000..47d207d05 --- /dev/null +++ b/bot/cogs/watchchannels/talentpool.py @@ -0,0 +1,233 @@ +import logging +import textwrap +from collections import ChainMap +from typing import Union + +from discord import Color, Embed, Member, User +from discord.ext.commands import Context, group + +from bot.api import ResponseCodeError +from bot.constants import Channels, Guild, Roles, Webhooks +from bot.decorators import with_role +from bot.pagination import LinePaginator +from .watchchannel import WatchChannel, proxy_user + +log = logging.getLogger(__name__) +STAFF_ROLES = Roles.owner, Roles.admin, Roles.moderator, Roles.helpers # <- In constants after the merge? + + +class TalentPool(WatchChannel): + """Relays messages of helper candidates to a watch channel to observe them.""" + + def __init__(self, bot) -> None: + super().__init__( + bot, + destination=Channels.talent_pool, + webhook_id=Webhooks.talent_pool, + api_endpoint='bot/nominations', + api_default_params={'active': 'true', 'ordering': '-inserted_at'}, + logger=log, + ) + + @group(name='talentpool', aliases=('tp', 'talent', 'nomination', 'n'), invoke_without_command=True) + @with_role(Roles.owner, Roles.admin, Roles.moderator) + async def nomination_group(self, ctx: Context) -> None: + """Highlights the activity of helper nominees by relaying their messages to the talent pool channel.""" + + await ctx.invoke(self.bot.get_command("help"), "talentpool") + + @nomination_group.command(name='watched', aliases=('all', 'list')) + @with_role(Roles.owner, Roles.admin, Roles.moderator) + async def watched_command(self, ctx: Context, update_cache: bool = True) -> None: + """ + Shows the users that are currently being monitored in the talent pool. + + The optional kwarg `update_cache` can be used to update the user + cache using the API before listing the users. + """ + await self.list_watched_users(ctx, update_cache) + + @nomination_group.command(name='watch', aliases=('w', 'add', 'a')) + @with_role(Roles.owner, Roles.admin, Roles.moderator) + async def watch_command(self, ctx: Context, user: Union[Member, User, proxy_user], *, reason: str) -> None: + """ + Relay messages sent by the given `user` to the `#talent-pool` channel. + + A `reason` for adding the user to the talent pool is required and will be displayed + in the header when relaying messages of this user to the channel. + """ + if user.bot: + await ctx.send(f":x: I'm sorry {ctx.author}, I'm afraid I can't do that. I only watch humans.") + return + + if isinstance(user, Member) and any(role.id in STAFF_ROLES for role in user.roles): + await ctx.send(f":x: Nominating staff members, eh? Here's a cookie :cookie:") + return + + if not await self.fetch_user_cache(): + await ctx.send(f":x: Failed to update the user cache; can't add {user}") + return + + if user.id in self.watched_users: + await ctx.send(":x: The specified user is already being watched in the talent pool") + return + + # Manual request with `raise_for_status` as False because we want the actual response + session = self.bot.api_client.session + url = self.bot.api_client._url_for(self.api_endpoint) + kwargs = { + 'json': { + 'actor': ctx.author.id, + 'reason': reason, + 'user': user.id + }, + 'raise_for_status': False, + } + async with session.post(url, **kwargs) as resp: + response_data = await resp.json() + + if resp.status == 400 and response_data.get('user', False): + await ctx.send(":x: The specified user can't be found in the database tables") + return + else: + resp.raise_for_status() + + self.watched_users[user.id] = response_data + await ctx.send(f":white_check_mark: Messages sent by {user} will now be relayed to the talent pool channel") + + @nomination_group.command(name='history', aliases=('info', 'search')) + @with_role(Roles.owner, Roles.admin, Roles.moderator) + async def history_command(self, ctx: Context, user: Union[User, proxy_user]) -> None: + """Shows the specified user's nomination history.""" + result = await self.bot.api_client.get( + self.api_endpoint, + params={ + 'user__id': str(user.id), + 'ordering': "-active,-inserted_at" + } + ) + if not result: + await ctx.send(":warning: This user has never been nominated") + return + + embed = Embed( + title=f"Nominations for {user.display_name} `({user.id})`", + color=Color.blue() + ) + lines = [self._nomination_to_string(nomination) for nomination in result] + await LinePaginator.paginate( + lines, + ctx=ctx, + embed=embed, + empty=True, + max_lines=3, + max_size=1000 + ) + + @nomination_group.command(name='unwatch', aliases=('end', )) + @with_role(Roles.owner, Roles.admin, Roles.moderator) + async def unwatch_command(self, ctx: Context, user: Union[User, proxy_user], *, reason: str) -> None: + """ + Ends the active nomination of the specified user with the given reason. + + Providing a `reason` is required. + """ + active_nomination = await self.bot.api_client.get( + self.api_endpoint, + params=ChainMap( + self.api_default_params, + {"user__id": str(user.id)} + ) + ) + + if not active_nomination: + await ctx.send(":x: The specified user does not have an active nomination") + return + + [nomination] = active_nomination + await self.bot.api_client.patch( + f"{self.api_endpoint}/{nomination['id']}", + json={'end_reason': reason, 'active': False} + ) + await ctx.send(f":white_check_mark: Messages sent by {user} will no longer be relayed") + self._remove_user(user.id) + + @nomination_group.group(name='edit', aliases=('e',), invoke_without_command=True) + @with_role(Roles.owner, Roles.admin, Roles.moderator) + async def nomination_edit_group(self, ctx: Context) -> None: + """Commands to edit nominations.""" + + await ctx.invoke(self.bot.get_command("help"), "talentpool", "edit") + + @nomination_edit_group.command(name='reason') + @with_role(Roles.owner, Roles.admin, Roles.moderator) + async def edit_reason_command(self, ctx: Context, nomination_id: int, *, reason: str) -> None: + """ + Edits the reason/unnominate reason for the nomination with the given `id` depending on the status. + + If the nomination is active, the reason for nominating the user will be edited; + If the nomination is no longer active, the reason for ending the nomination will be edited instead. + """ + try: + nomination = await self.bot.api_client.get(f"{self.api_endpoint}/{nomination_id}") + except ResponseCodeError as e: + if e.response.status == 404: + self.log.trace(f"Nomination API 404: Can't nomination with id {nomination_id}") + await ctx.send(f":x: Can't find a nomination with id `{nomination_id}`") + return + else: + raise + + field = "reason" if nomination["active"] else "end_reason" + + self.log.trace(f"Changing {field} for nomination with id {nomination_id} to {reason}") + + await self.bot.api_client.patch( + f"{self.api_endpoint}/{nomination_id}", + json={field: reason} + ) + + await ctx.send(f":white_check_mark: Updated the {field} of the nomination!") + + def _nomination_to_string(self, nomination_object: dict) -> str: + """Creates a string representation of a nomination.""" + guild = self.bot.get_guild(Guild.id) + + actor_id = nomination_object["actor"] + actor = guild.get_member(actor_id) + + active = nomination_object["active"] + log.debug(active) + log.debug(type(nomination_object["inserted_at"])) + + start_date = self._get_human_readable(nomination_object["inserted_at"]) + if active: + lines = textwrap.dedent( + f""" + =============== + Status: **Active** + Date: {start_date} + Actor: {actor.mention if actor else actor_id} + Reason: {nomination_object["reason"]} + Nomination ID: `{nomination_object["id"]}` + =============== + """ + ) + else: + end_date = self._get_human_readable(nomination_object["ended_at"]) + lines = textwrap.dedent( + f""" + =============== + Status: Inactive + Date: {start_date} + Actor: {actor.mention if actor else actor_id} + Reason: {nomination_object["reason"]} + + End date: {end_date} + Unwatch reason: {nomination_object["end_reason"]} + Nomination ID: `{nomination_object["id"]}` + =============== + """ + ) + + return lines.strip() diff --git a/bot/cogs/watchchannels/watchchannel.py b/bot/cogs/watchchannels/watchchannel.py new file mode 100644 index 000000000..3a24e3f21 --- /dev/null +++ b/bot/cogs/watchchannels/watchchannel.py @@ -0,0 +1,353 @@ +import asyncio +import datetime +import logging +import re +import textwrap +from abc import ABC, abstractmethod +from collections import defaultdict, deque +from dataclasses import dataclass +from typing import Optional + +import discord +from discord import Color, Embed, Message, Object, errors +from discord.ext.commands import BadArgument, Bot, Context + +from bot.api import ResponseCodeError +from bot.cogs.modlog import ModLog +from bot.constants import BigBrother as BigBrotherConfig, Guild as GuildConfig, Icons +from bot.pagination import LinePaginator +from bot.utils import messages +from bot.utils.time import time_since + +log = logging.getLogger(__name__) + +URL_RE = re.compile(r"(https?://[^\s]+)") + + +def proxy_user(user_id: str) -> Object: + """A proxy user object that mocks a real User instance for when the later is not available.""" + try: + user_id = int(user_id) + except ValueError: + raise BadArgument + + user = Object(user_id) + user.mention = user.id + user.display_name = f"<@{user.id}>" + user.avatar_url_as = lambda static_format: None + user.bot = False + + return user + + +@dataclass +class MessageHistory: + last_author: Optional[int] = None + last_channel: Optional[int] = None + message_count: int = 0 + + +class WatchChannel(ABC): + """ABC with functionality for relaying users' messages to a certain channel.""" + + @abstractmethod + def __init__(self, bot: Bot, destination, webhook_id, api_endpoint, api_default_params, logger) -> None: + self.bot = bot + + self.destination = destination # E.g., Channels.big_brother_logs + self.webhook_id = webhook_id # E.g., Webhooks.big_brother + self.api_endpoint = api_endpoint # E.g., 'bot/infractions' + self.api_default_params = api_default_params # E.g., {'active': 'true', 'type': 'watch'} + self.log = logger # Logger of the child cog for a correct name in the logs + + self._consume_task = None + self.watched_users = defaultdict(dict) + self.message_queue = defaultdict(lambda: defaultdict(deque)) + self.consumption_queue = {} + self.retries = 5 + self.retry_delay = 10 + self.channel = None + self.webhook = None + self.message_history = MessageHistory() + + self._start = self.bot.loop.create_task(self.start_watchchannel()) + + @property + def modlog(self) -> ModLog: + """Provides access to the ModLog cog for alert purposes.""" + return self.bot.get_cog("ModLog") + + @property + def consuming_messages(self) -> bool: + """Checks if a consumption task is currently running.""" + if self._consume_task is None: + return False + + if self._consume_task.done(): + exc = self._consume_task.exception() + if exc: + self.log.exception( + f"The message queue consume task has failed with:", + exc_info=exc + ) + return False + + return True + + async def start_watchchannel(self) -> None: + """Starts the watch channel by getting the channel, webhook, and user cache ready.""" + await self.bot.wait_until_ready() + + # After updating d.py, this block can be replaced by `fetch_channel` with a try-except + for attempt in range(1, self.retries+1): + self.channel = self.bot.get_channel(self.destination) + if self.channel is None: + if attempt < self.retries: + await asyncio.sleep(self.retry_delay) + else: + break + else: + self.log.error(f"Failed to retrieve the text channel with id {self.destination}") + + # `get_webhook_info` has been renamed to `fetch_webhook` in newer versions of d.py + try: + self.webhook = await self.bot.get_webhook_info(self.webhook_id) + except (discord.HTTPException, discord.NotFound, discord.Forbidden): + self.log.exception(f"Failed to fetch webhook with id `{self.webhook_id}`") + + if self.channel is None or self.webhook is None: + self.log.error("Failed to start the watch channel; unloading the cog.") + + message = textwrap.dedent( + f""" + An error occurred while loading the text channel or webhook. + + TextChannel: {"**Failed to load**" if self.channel is None else "Loaded successfully"} + Webhook: {"**Failed to load**" if self.webhook is None else "Loaded successfully"} + + The Cog has been unloaded. + """ + ) + + await self.modlog.send_log_message( + title=f"Error: Failed to initialize the {self.__class__.__name__} watch channel", + text=message, + ping_everyone=True, + icon_url=Icons.token_removed, + colour=Color.red() + ) + + self.bot.remove_cog(self.__class__.__name__) + return + + if not await self.fetch_user_cache(): + await self.modlog.send_log_message( + title=f"Warning: Failed to retrieve user cache for the {self.__class__.__name__} watch channel", + text="Could not retrieve the list of watched users from the API and messages will not be relayed.", + ping_everyone=True, + icon=Icons.token_removed, + color=Color.red() + ) + + async def fetch_user_cache(self) -> bool: + """ + Fetches watched users from the API and updates the watched user cache accordingly. + + This function returns `True` if the update succeeded. + """ + try: + data = await self.bot.api_client.get(self.api_endpoint, params=self.api_default_params) + except ResponseCodeError as err: + self.log.exception(f"Failed to fetch the watched users from the API", exc_info=err) + return False + + self.watched_users = defaultdict(dict) + + for entry in data: + user_id = entry.pop('user') + self.watched_users[user_id] = entry + + return True + + async def on_message(self, msg: Message) -> None: + """Queues up messages sent by watched users.""" + if msg.author.id in self.watched_users: + if not self.consuming_messages: + self._consume_task = self.bot.loop.create_task(self.consume_messages()) + + self.log.trace(f"Received message: {msg.content} ({len(msg.attachments)} attachments)") + self.message_queue[msg.author.id][msg.channel.id].append(msg) + + async def consume_messages(self, delay_consumption: bool = True) -> None: + """Consumes the message queues to log watched users' messages.""" + if delay_consumption: + self.log.trace(f"Sleeping {BigBrotherConfig.log_delay} seconds before consuming message queue") + await asyncio.sleep(BigBrotherConfig.log_delay) + + self.log.trace(f"Started consuming the message queue") + + # If the previous consumption Task failed, first consume the existing comsumption_queue + if not self.consumption_queue: + self.consumption_queue = self.message_queue.copy() + self.message_queue.clear() + + for user_channel_queues in self.consumption_queue.values(): + for channel_queue in user_channel_queues.values(): + while channel_queue: + msg = channel_queue.popleft() + + self.log.trace(f"Consuming message {msg.id} ({len(msg.attachments)} attachments)") + await self.relay_message(msg) + + self.consumption_queue.clear() + + if self.message_queue: + self.log.trace("Channel queue not empty: Continuing consuming queues") + self._consume_task = self.bot.loop.create_task(self.consume_messages(delay_consumption=False)) + else: + self.log.trace("Done consuming messages.") + + async def webhook_send( + self, + content: Optional[str] = None, + username: Optional[str] = None, + avatar_url: Optional[str] = None, + embed: Optional[Embed] = None, + ) -> None: + """Sends a message to the webhook with the specified kwargs.""" + try: + await self.webhook.send(content=content, username=username, avatar_url=avatar_url, embed=embed) + except discord.HTTPException as exc: + self.log.exception( + f"Failed to send a message to the webhook", + exc_info=exc + ) + + async def relay_message(self, msg: Message) -> None: + """Relays the message to the relevant watch channel.""" + limit = BigBrotherConfig.header_message_limit + + if ( + msg.author.id != self.message_history.last_author + or msg.channel.id != self.message_history.last_channel + or self.message_history.message_count >= limit + ): + self.message_history = MessageHistory(last_author=msg.author.id, last_channel=msg.channel.id) + + await self.send_header(msg) + + cleaned_content = msg.clean_content + + if cleaned_content: + # Put all non-media URLs in a code block to prevent embeds + media_urls = {embed.url for embed in msg.embeds if embed.type in ("image", "video")} + for url in URL_RE.findall(cleaned_content): + if url not in media_urls: + cleaned_content = cleaned_content.replace(url, f"`{url}`") + await self.webhook_send( + cleaned_content, + username=msg.author.display_name, + avatar_url=msg.author.avatar_url + ) + + if msg.attachments: + try: + await messages.send_attachments(msg, self.webhook) + except (errors.Forbidden, errors.NotFound): + e = Embed( + description=":x: **This message contained an attachment, but it could not be retrieved**", + color=Color.red() + ) + await self.webhook_send( + embed=e, + username=msg.author.display_name, + avatar_url=msg.author.avatar_url + ) + except discord.HTTPException as exc: + self.log.exception( + f"Failed to send an attachment to the webhook", + exc_info=exc + ) + + self.message_history.message_count += 1 + + async def send_header(self, msg) -> None: + """Sends a header embed with information about the relayed messages to the watch channel.""" + user_id = msg.author.id + + guild = self.bot.get_guild(GuildConfig.id) + actor = guild.get_member(self.watched_users[user_id]['actor']) + actor = actor.display_name if actor else self.watched_users[user_id]['actor'] + + inserted_at = self.watched_users[user_id]['inserted_at'] + time_delta = self._get_time_delta(inserted_at) + + reason = self.watched_users[user_id]['reason'] + + embed = Embed(description=f"{msg.author.mention} in [#{msg.channel.name}]({msg.jump_url})") + embed.set_footer(text=f"Added {time_delta} by {actor} | Reason: {reason}") + + await self.webhook_send(embed=embed, username=msg.author.display_name, avatar_url=msg.author.avatar_url) + + async def list_watched_users(self, ctx: Context, update_cache: bool = True) -> None: + """ + Gives an overview of the watched user list for this channel. + + The optional kwarg `update_cache` specifies whether the cache should + be refreshed by polling the API. + """ + if update_cache: + if not await self.fetch_user_cache(): + await ctx.send(f":x: Failed to update {self.__class__.__name__} user cache, serving from cache") + update_cache = False + + lines = [] + for user_id, user_data in self.watched_users.items(): + inserted_at = user_data['inserted_at'] + time_delta = self._get_time_delta(inserted_at) + lines.append(f"• <@{user_id}> (added {time_delta})") + + lines = lines or ("There's nothing here yet.",) + embed = Embed( + title=f"{self.__class__.__name__} watched users ({'updated' if update_cache else 'cached'})", + color=Color.blue() + ) + await LinePaginator.paginate(lines, ctx, embed, empty=False) + + @staticmethod + def _get_time_delta(time_string: str) -> str: + """Returns the time in human-readable time delta format.""" + date_time = datetime.datetime.strptime( + time_string, + "%Y-%m-%dT%H:%M:%S.%fZ" + ).replace(tzinfo=None) + time_delta = time_since(date_time, precision="minutes", max_units=1) + + return time_delta + + @staticmethod + def _get_human_readable(time_string: str, output_format: str = "%Y-%m-%d %H:%M:%S") -> str: + date_time = datetime.datetime.strptime( + time_string, + "%Y-%m-%dT%H:%M:%S.%fZ" + ).replace(tzinfo=None) + return date_time.strftime(output_format) + + def _remove_user(self, user_id: int) -> None: + """Removes a user from a watch channel.""" + self.watched_users.pop(user_id, None) + self.message_queue.pop(user_id, None) + self.consumption_queue.pop(user_id, None) + + def cog_unload(self) -> None: + """Takes care of unloading the cog and canceling the consumption task.""" + self.log.trace(f"Unloading the cog") + if not self._consume_task.done(): + self._consume_task.cancel() + try: + self._consume_task.result() + except asyncio.CancelledError as e: + self.log.exception( + f"The consume task was canceled. Messages may be lost.", + exc_info=e + ) diff --git a/bot/constants.py b/bot/constants.py index 17e60a418..ead26c91d 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -356,6 +356,14 @@ class Channels(metaclass=YAMLGetter): verification: int +class Webhooks(metaclass=YAMLGetter): + section = "guild" + subsection = "webhooks" + + talent_pool: int + big_brother: int + + class Roles(metaclass=YAMLGetter): section = "guild" subsection = "roles" @@ -390,18 +398,12 @@ class Keys(metaclass=YAMLGetter): site_api: str -class RabbitMQ(metaclass=YAMLGetter): - section = "rabbitmq" - - host: str - password: str - port: int - username: str - - class URLs(metaclass=YAMLGetter): section = "urls" + # Snekbox endpoints + snekbox_eval_api: str + # Discord API endpoints discord_api: str discord_invite_api: str diff --git a/bot/converters.py b/bot/converters.py index 91f30ac5e..30ea7ca0f 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -1,6 +1,8 @@ import logging +from datetime import datetime from ssl import CertificateError +import dateparser import discord from aiohttp import ClientConnectorError from discord.ext.commands import BadArgument, Context, Converter @@ -151,3 +153,22 @@ class TagContentConverter(Converter): raise BadArgument("Tag contents should not be empty, or filled with whitespace.") return tag_content + + +class ExpirationDate(Converter): + DATEPARSER_SETTINGS = { + 'PREFER_DATES_FROM': 'future', + 'TIMEZONE': 'UTC', + 'TO_TIMEZONE': 'UTC' + } + + async def convert(self, ctx, expiration_string: str): + expiry = dateparser.parse(expiration_string, settings=self.DATEPARSER_SETTINGS) + if expiry is None: + raise BadArgument(f"Failed to parse expiration date from `{expiration_string}`") + + now = datetime.utcnow() + if expiry < now: + expiry = now + (now - expiry) + + return expiry diff --git a/bot/utils/messages.py b/bot/utils/messages.py index fc38b0127..94a8b36ed 100644 --- a/bot/utils/messages.py +++ b/bot/utils/messages.py @@ -1,9 +1,9 @@ import asyncio import contextlib from io import BytesIO -from typing import Sequence +from typing import Sequence, Union -from discord import Embed, File, Message, TextChannel +from discord import Embed, File, Message, TextChannel, Webhook from discord.abc import Snowflake from discord.errors import HTTPException @@ -78,9 +78,9 @@ async def wait_for_deletion( await message.delete() -async def send_attachments(message: Message, destination: TextChannel): +async def send_attachments(message: Message, destination: Union[TextChannel, Webhook]): """ - Re-uploads each attachment in a message to the given channel. + Re-uploads each attachment in a message to the given channel or webhook. Each attachment is sent as a separate message to more easily comply with the 8 MiB request size limit. If attachments are too large, they are instead grouped into a single embed which links to them. @@ -97,7 +97,16 @@ async def send_attachments(message: Message, destination: TextChannel): if attachment.size <= MAX_SIZE - 512: with BytesIO() as file: await attachment.save(file) - await destination.send(file=File(file, filename=attachment.filename)) + attachment_file = File(file, filename=attachment.filename) + + if isinstance(destination, TextChannel): + await destination.send(file=attachment_file) + else: + await destination.send( + file=attachment_file, + username=message.author.display_name, + avatar_url=message.author.avatar_url + ) else: large.append(attachment) except HTTPException as e: @@ -109,4 +118,11 @@ async def send_attachments(message: Message, destination: TextChannel): if large: embed = Embed(description=f"\n".join(f"[{attachment.filename}]({attachment.url})" for attachment in large)) embed.set_footer(text="Attachments exceed upload size limit.") - await destination.send(embed=embed) + if isinstance(destination, TextChannel): + await destination.send(embed=embed) + else: + await destination.send( + embed=embed, + username=message.author.display_name, + avatar_url=message.author.avatar_url + ) diff --git a/bot/utils/moderation.py b/bot/utils/moderation.py index 724b455bc..c1eb98dd6 100644 --- a/bot/utils/moderation.py +++ b/bot/utils/moderation.py @@ -1,11 +1,12 @@ import logging +from datetime import datetime from typing import Union from aiohttp import ClientError from discord import Member, Object, User from discord.ext.commands import Context -from bot.constants import Keys, URLs +from bot.constants import Keys log = logging.getLogger(__name__) @@ -13,33 +14,33 @@ HEADERS = {"X-API-KEY": Keys.site_api} async def post_infraction( - ctx: Context, user: Union[Member, Object, User], type: str, reason: str, duration: str = None, hidden: bool = False + ctx: Context, + user: Union[Member, Object, User], + type: str, + reason: str, + expires_at: datetime = None, + hidden: bool = False, + active: bool = True, ): payload = { - "type": type, + "actor": ctx.message.author.id, + "hidden": hidden, "reason": reason, - "user_id": str(user.id), - "actor_id": str(ctx.message.author.id), - "hidden": hidden + "type": type, + "user": user.id, + "active": active } - if duration: - payload['duration'] = duration + if expires_at: + payload['expires_at'] = expires_at.isoformat() try: - response = await ctx.bot.http_session.post( - URLs.site_infractions, - headers=HEADERS, - json=payload + response = await ctx.bot.api_client.post( + 'bot/infractions', json=payload ) except ClientError: log.exception("There was an error adding an infraction.") await ctx.send(":x: There was an error adding the infraction.") return - response_object = await response.json() - if "error_code" in response_object: - await ctx.send(f":x: There was an error adding the infraction: {response_object['error_message']}") - return - - return response_object + return response diff --git a/bot/utils/service_discovery.py b/bot/utils/service_discovery.py deleted file mode 100644 index 8d79096bd..000000000 --- a/bot/utils/service_discovery.py +++ /dev/null @@ -1,22 +0,0 @@ -import datetime -import socket -import time -from contextlib import closing - -from bot.constants import RabbitMQ - -THIRTY_SECONDS = datetime.timedelta(seconds=30) - - -def wait_for_rmq(): - start = datetime.datetime.now() - - while True: - if datetime.datetime.now() - start > THIRTY_SECONDS: - return False - - with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as sock: - if sock.connect_ex((RabbitMQ.host, RabbitMQ.port)) == 0: - return True - - time.sleep(0.5) diff --git a/bot/utils/time.py b/bot/utils/time.py index 8e5d4e1bd..a330c9cd8 100644 --- a/bot/utils/time.py +++ b/bot/utils/time.py @@ -106,7 +106,7 @@ async def wait_until(time: datetime.datetime): :param time: A datetime.datetime object to wait until. """ - delay = time - datetime.datetime.now(tz=datetime.timezone.utc) + delay = time - datetime.datetime.utcnow() delay_seconds = delay.total_seconds() if delay_seconds > 1.0: diff --git a/config-default.yml b/config-default.yml index 91b4d6cce..2f5dcf5dc 100644 --- a/config-default.yml +++ b/config-default.yml @@ -136,6 +136,10 @@ guild: rockstars: &ROCKSTARS_ROLE 458226413825294336 team_leader: 501324292341104650 + webhooks: + talent_pool: 569145364800602132 + big_brother: 569133704568373283 + filter: @@ -228,13 +232,6 @@ keys: site_api: !ENV "BOT_API_KEY" -rabbitmq: - host: "pdrmq" - password: !ENV ["RABBITMQ_DEFAULT_PASS", "guest"] - port: 5672 - username: !ENV ["RABBITMQ_DEFAULT_USER", "guest"] - - urls: # PyDis site vars site: &DOMAIN "pythondiscord.com" @@ -262,6 +259,9 @@ urls: site_user_complete_api: !JOIN [*SCHEMA, *API, "/bot/users/complete"] paste_service: !JOIN [*SCHEMA, *PASTE, "/{key}"] + # Snekbox + snekbox_eval_api: "http://localhost:8060/eval" + # Env vars deploy: !ENV "DEPLOY_URL" status: !ENV "STATUS_URL" diff --git a/docker/base.Dockerfile b/docker/base.Dockerfile deleted file mode 100644 index e46db756a..000000000 --- a/docker/base.Dockerfile +++ /dev/null @@ -1,17 +0,0 @@ -FROM python:3.6-alpine3.7 - -RUN apk add --update tini -RUN apk add --update build-base -RUN apk add --update libffi-dev -RUN apk add --update zlib -RUN apk add --update jpeg-dev -RUN apk add --update libxml2 libxml2-dev libxslt-dev -RUN apk add --update zlib-dev -RUN apk add --update freetype-dev -RUN apk add --update git - -ENV LIBRARY_PATH=/lib:/usr/lib -ENV PIPENV_VENV_IN_PROJECT=1 -ENV PIPENV_IGNORE_VIRTUALENVS=1 -ENV PIPENV_NOSPIN=1 -ENV PIPENV_HIDE_EMOJIS=1 diff --git a/docker/bot.Dockerfile b/docker/bot.Dockerfile deleted file mode 100644 index 5a07a612b..000000000 --- a/docker/bot.Dockerfile +++ /dev/null @@ -1,17 +0,0 @@ -FROM pythondiscord/bot-base:latest - -ENV PIPENV_VENV_IN_PROJECT=1 -ENV PIPENV_IGNORE_VIRTUALENVS=1 -ENV PIPENV_NOSPIN=1 -ENV PIPENV_HIDE_EMOJIS=1 - -RUN pip install -U pipenv - -RUN mkdir -p /bot -COPY . /bot -WORKDIR /bot - -RUN pipenv install --deploy - -ENTRYPOINT ["/sbin/tini", "--"] -CMD ["pipenv", "run", "start"] diff --git a/scripts/deploy-azure.sh b/scripts/deploy-azure.sh index 6b3dea508..af69ab46b 100644 --- a/scripts/deploy-azure.sh +++ b/scripts/deploy-azure.sh @@ -2,30 +2,11 @@ cd .. -# Build and deploy on master branch, only if not a pull request -if [[ ($BUILD_SOURCEBRANCHNAME == 'master') && ($SYSTEM_PULLREQUEST_PULLREQUESTID == '') ]]; then - changed_lines=$(git diff HEAD~1 HEAD docker/base.Dockerfile | wc -l) - - if [ $changed_lines != '0' ]; then - echo "base.Dockerfile was changed" - - echo "Building bot base" - docker build -t pythondiscord/bot-base:latest -f docker/base.Dockerfile . - - echo "Pushing image to Docker Hub" - docker push pythondiscord/bot-base:latest - else - echo "base.Dockerfile was not changed, not building" - fi - +# Build and deploy on django branch, only if not a pull request +if [[ ($BUILD_SOURCEBRANCHNAME == 'django') && ($SYSTEM_PULLREQUEST_PULLREQUESTID == '') ]]; then echo "Building image" - docker build -t pythondiscord/bot:latest -f docker/bot.Dockerfile . + docker build -t pythondiscord/bot:django . echo "Pushing image" - docker push pythondiscord/bot:latest - - echo "Deploying container" - curl -H "token: $1" $2 -else - echo "Skipping deploy" -fi
\ No newline at end of file + docker push pythondiscord/bot:django +fi diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/tests/__init__.py diff --git a/tests/cogs/__init__.py b/tests/cogs/__init__.py new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/tests/cogs/__init__.py diff --git a/tests/cogs/sync/__init__.py b/tests/cogs/sync/__init__.py new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/tests/cogs/sync/__init__.py diff --git a/tests/cogs/sync/test_roles.py b/tests/cogs/sync/test_roles.py new file mode 100644 index 000000000..18682f39f --- /dev/null +++ b/tests/cogs/sync/test_roles.py @@ -0,0 +1,64 @@ +from bot.cogs.sync.syncers import Role, get_roles_for_sync + + +def test_get_roles_for_sync_empty_return_for_equal_roles(): + api_roles = {Role(id=41, name='name', colour=33, permissions=0x8)} + guild_roles = {Role(id=41, name='name', colour=33, permissions=0x8)} + + assert get_roles_for_sync(guild_roles, api_roles) == (set(), set()) + + +def test_get_roles_for_sync_returns_roles_to_update_with_non_id_diff(): + api_roles = {Role(id=41, name='old name', colour=35, permissions=0x8)} + guild_roles = {Role(id=41, name='new name', colour=33, permissions=0x8)} + + assert get_roles_for_sync(guild_roles, api_roles) == ( + set(), + guild_roles + ) + + +def test_get_roles_only_returns_roles_that_require_update(): + api_roles = { + Role(id=41, name='old name', colour=33, permissions=0x8), + Role(id=53, name='other role', colour=55, permissions=0) + } + guild_roles = { + Role(id=41, name='new name', colour=35, permissions=0x8), + Role(id=53, name='other role', colour=55, permissions=0) + } + + assert get_roles_for_sync(guild_roles, api_roles) == ( + set(), + {Role(id=41, name='new name', colour=35, permissions=0x8)}, + ) + + +def test_get_roles_returns_new_roles_in_first_tuple_element(): + api_roles = { + Role(id=41, name='name', colour=35, permissions=0x8), + } + guild_roles = { + Role(id=41, name='name', colour=35, permissions=0x8), + Role(id=53, name='other role', colour=55, permissions=0) + } + + assert get_roles_for_sync(guild_roles, api_roles) == ( + {Role(id=53, name='other role', colour=55, permissions=0)}, + set() + ) + + +def test_get_roles_returns_roles_to_update_and_new_roles(): + api_roles = { + Role(id=41, name='old name', colour=35, permissions=0x8), + } + guild_roles = { + Role(id=41, name='new name', colour=40, permissions=0x16), + Role(id=53, name='other role', colour=55, permissions=0) + } + + assert get_roles_for_sync(guild_roles, api_roles) == ( + {Role(id=53, name='other role', colour=55, permissions=0)}, + {Role(id=41, name='new name', colour=40, permissions=0x16)} + ) diff --git a/tests/cogs/sync/test_users.py b/tests/cogs/sync/test_users.py new file mode 100644 index 000000000..a863ae35b --- /dev/null +++ b/tests/cogs/sync/test_users.py @@ -0,0 +1,69 @@ +from bot.cogs.sync.syncers import User, get_users_for_sync + + +def fake_user(**kwargs): + kwargs.setdefault('id', 43) + kwargs.setdefault('name', 'bob the test man') + kwargs.setdefault('discriminator', 1337) + kwargs.setdefault('avatar_hash', None) + kwargs.setdefault('roles', (666,)) + kwargs.setdefault('in_guild', True) + return User(**kwargs) + + +def test_get_users_for_sync_returns_nothing_for_empty_params(): + assert get_users_for_sync({}, {}) == (set(), set()) + + +def test_get_users_for_sync_returns_nothing_for_equal_users(): + api_users = {43: fake_user()} + guild_users = {43: fake_user()} + + assert get_users_for_sync(guild_users, api_users) == (set(), set()) + + +def test_get_users_for_sync_returns_users_to_update_on_non_id_field_diff(): + api_users = {43: fake_user()} + guild_users = {43: fake_user(name='new fancy name')} + + assert get_users_for_sync(guild_users, api_users) == ( + set(), + {fake_user(name='new fancy name')} + ) + + +def test_get_users_for_sync_returns_users_to_create_with_new_ids_on_guild(): + api_users = {43: fake_user()} + guild_users = {43: fake_user(), 63: fake_user(id=63)} + + assert get_users_for_sync(guild_users, api_users) == ( + {fake_user(id=63)}, + set() + ) + + +def test_get_users_for_sync_updates_in_guild_field_on_user_leave(): + api_users = {43: fake_user(), 63: fake_user(id=63)} + guild_users = {43: fake_user()} + + assert get_users_for_sync(guild_users, api_users) == ( + set(), + {fake_user(id=63, in_guild=False)} + ) + + +def test_get_users_for_sync_updates_and_creates_users_as_needed(): + api_users = {43: fake_user()} + guild_users = {63: fake_user(id=63)} + + assert get_users_for_sync(guild_users, api_users) == ( + {fake_user(id=63)}, + {fake_user(in_guild=False)} + ) + + +def test_get_users_for_sync_does_not_duplicate_update_users(): + api_users = {43: fake_user(in_guild=False)} + guild_users = {} + + assert get_users_for_sync(guild_users, api_users) == (set(), set()) @@ -2,5 +2,5 @@ max-line-length=120 application_import_names=bot exclude=.cache,.venv -ignore=B311,W503,E226,S311 +ignore=B311,W503,E226,S311,T000 import-order-style=pycharm |