aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--Dockerfile35
-rw-r--r--Pipfile4
-rw-r--r--Pipfile.lock295
-rw-r--r--azure-pipelines.yml14
-rw-r--r--bot/__init__.py2
-rw-r--r--bot/__main__.py25
-rw-r--r--bot/api.py137
-rw-r--r--bot/cogs/alias.py34
-rw-r--r--bot/cogs/antispam.py2
-rw-r--r--bot/cogs/bigbrother.py501
-rw-r--r--bot/cogs/clean.py2
-rw-r--r--bot/cogs/defcon.py61
-rw-r--r--bot/cogs/doc.py153
-rw-r--r--bot/cogs/error_handler.py92
-rw-r--r--bot/cogs/events.py311
-rw-r--r--bot/cogs/information.py19
-rw-r--r--bot/cogs/moderation.py419
-rw-r--r--bot/cogs/modlog.py74
-rw-r--r--bot/cogs/off_topic_names.py72
-rw-r--r--bot/cogs/reminders.py236
-rw-r--r--bot/cogs/rmq.py229
-rw-r--r--bot/cogs/rules.py104
-rw-r--r--bot/cogs/site.py44
-rw-r--r--bot/cogs/snekbox.py264
-rw-r--r--bot/cogs/superstarify/__init__.py (renamed from bot/cogs/superstarify.py)254
-rw-r--r--bot/cogs/superstarify/stars.py86
-rw-r--r--bot/cogs/sync/__init__.py10
-rw-r--r--bot/cogs/sync/cog.py180
-rw-r--r--bot/cogs/sync/syncers.py227
-rw-r--r--bot/cogs/tags.py257
-rw-r--r--bot/cogs/watchchannels/__init__.py15
-rw-r--r--bot/cogs/watchchannels/bigbrother.py100
-rw-r--r--bot/cogs/watchchannels/talentpool.py233
-rw-r--r--bot/cogs/watchchannels/watchchannel.py353
-rw-r--r--bot/constants.py20
-rw-r--r--bot/converters.py21
-rw-r--r--bot/utils/messages.py28
-rw-r--r--bot/utils/moderation.py37
-rw-r--r--bot/utils/service_discovery.py22
-rw-r--r--bot/utils/time.py2
-rw-r--r--config-default.yml14
-rw-r--r--docker/base.Dockerfile17
-rw-r--r--docker/bot.Dockerfile17
-rw-r--r--scripts/deploy-azure.sh29
-rw-r--r--tests/__init__.py0
-rw-r--r--tests/cogs/__init__.py0
-rw-r--r--tests/cogs/sync/__init__.py0
-rw-r--r--tests/cogs/sync/test_roles.py64
-rw-r--r--tests/cogs/sync/test_users.py69
-rw-r--r--tox.ini2
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"]
diff --git a/Pipfile b/Pipfile
index 494a8a6ff..e2ad73ef3 100644
--- a/Pipfile
+++ b/Pipfile
@@ -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())
diff --git a/tox.ini b/tox.ini
index c6fa513f4..c84827570 100644
--- a/tox.ini
+++ b/tox.ini
@@ -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