aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar sco1 <[email protected]>2019-09-18 14:00:57 -0700
committerGravatar sco1 <[email protected]>2019-09-18 14:10:06 -0700
commit2ac0c6df978f20a488b3eb026753c8a972cb2554 (patch)
tree43fc70b14b517139dea5324e762ba92a9e211e28
parentDocstring linting chunk 7 (diff)
parentMerge pull request #436 from python-discord/enhance-offtopicnames-search (diff)
Merge branch 'master' into flake8-plugins
-rw-r--r--.gitignore3
-rw-r--r--Dockerfile12
-rw-r--r--Pipfile4
-rw-r--r--Pipfile.lock187
-rw-r--r--azure-pipelines.yml16
-rw-r--r--bot/__main__.py15
-rw-r--r--bot/api.py43
-rw-r--r--bot/cogs/alias.py21
-rw-r--r--bot/cogs/antispam.py222
-rw-r--r--bot/cogs/bot.py38
-rw-r--r--bot/cogs/clean.py7
-rw-r--r--bot/cogs/cogs.py36
-rw-r--r--bot/cogs/defcon.py14
-rw-r--r--bot/cogs/deployment.py79
-rw-r--r--bot/cogs/doc.py3
-rw-r--r--bot/cogs/error_handler.py88
-rw-r--r--bot/cogs/eval.py4
-rw-r--r--bot/cogs/filtering.py12
-rw-r--r--bot/cogs/free.py4
-rw-r--r--bot/cogs/help.py6
-rw-r--r--bot/cogs/information.py67
-rw-r--r--bot/cogs/jams.py27
-rw-r--r--bot/cogs/logging.py10
-rw-r--r--bot/cogs/moderation.py353
-rw-r--r--bot/cogs/modlog.py39
-rw-r--r--bot/cogs/off_topic_names.py50
-rw-r--r--bot/cogs/reddit.py7
-rw-r--r--bot/cogs/reminders.py5
-rw-r--r--bot/cogs/security.py11
-rw-r--r--bot/cogs/site.py29
-rw-r--r--bot/cogs/snekbox.py34
-rw-r--r--bot/cogs/superstarify/__init__.py10
-rw-r--r--bot/cogs/sync/cog.py13
-rw-r--r--bot/cogs/tags.py9
-rw-r--r--bot/cogs/token_remover.py7
-rw-r--r--bot/cogs/utils.py17
-rw-r--r--bot/cogs/verification.py20
-rw-r--r--bot/cogs/watchchannels/bigbrother.py4
-rw-r--r--bot/cogs/watchchannels/talentpool.py4
-rw-r--r--bot/cogs/watchchannels/watchchannel.py30
-rw-r--r--bot/cogs/wolfram.py38
-rw-r--r--bot/constants.py16
-rw-r--r--bot/converters.py2
-rw-r--r--bot/decorators.py11
-rw-r--r--bot/patches/__init__.py6
-rw-r--r--bot/patches/message_edited_at.py33
-rw-r--r--bot/utils/__init__.py9
-rw-r--r--bot/utils/checks.py2
-rw-r--r--bot/utils/moderation.py44
-rw-r--r--bot/utils/scheduling.py6
-rw-r--r--config-default.yml18
-rw-r--r--scripts/deploy-azure.sh8
-rw-r--r--scripts/deploy.sh32
-rw-r--r--tests/cogs/test_antispam.py30
-rw-r--r--tests/cogs/test_information.py163
-rw-r--r--tests/cogs/test_security.py54
-rw-r--r--tests/cogs/test_token_remover.py133
-rw-r--r--tests/conftest.py32
-rw-r--r--tests/helpers.py29
-rw-r--r--tests/test_api.py106
-rw-r--r--tests/test_constants.py23
-rw-r--r--tests/test_converters.py93
-rw-r--r--tests/test_pagination.py29
-rw-r--r--tests/test_resources.py18
-rw-r--r--tests/utils/__init__.py0
-rw-r--r--tests/utils/test_checks.py66
-rw-r--r--tox.ini9
67 files changed, 1763 insertions, 807 deletions
diff --git a/.gitignore b/.gitignore
index be4f43c7f..cda3aeb9f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -114,3 +114,6 @@ log.*
# Custom user configuration
config.yml
+
+# JUnit XML reports from pytest
+junit.xml
diff --git a/Dockerfile b/Dockerfile
index 864b4e557..aa6333380 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -14,15 +14,7 @@ RUN apk add --no-cache \
zlib-dev
ENV \
- LIBRARY_PATH=/lib:/usr/lib \
- PIPENV_HIDE_EMOJIS=1 \
- PIPENV_HIDE_EMOJIS=1 \
- PIPENV_IGNORE_VIRTUALENVS=1 \
- PIPENV_IGNORE_VIRTUALENVS=1 \
- PIPENV_NOSPIN=1 \
- PIPENV_NOSPIN=1 \
- PIPENV_VENV_IN_PROJECT=1 \
- PIPENV_VENV_IN_PROJECT=1
+ LIBRARY_PATH=/lib:/usr/lib
RUN pip install -U pipenv
@@ -32,4 +24,4 @@ COPY . .
RUN pipenv install --deploy --system
ENTRYPOINT ["/sbin/tini", "--"]
-CMD ["pipenv", "run", "start"]
+CMD ["python3", "-m", "bot"]
diff --git a/Pipfile b/Pipfile
index 1e29bd649..6a58054c1 100644
--- a/Pipfile
+++ b/Pipfile
@@ -4,7 +4,7 @@ verify_ssl = true
name = "pypi"
[packages]
-discord-py = {git = "https://github.com/Rapptz/discord.py.git",extras = ["voice"],ref = "860d6a9ace8248dfeec18b8b159e7b757d9f56bb",editable = true}
+discord-py = "~=1.2"
aiodns = "*"
logmatic-python = "*"
aiohttp = "*"
@@ -18,6 +18,7 @@ python-dateutil = "*"
deepdiff = "*"
requests = "*"
dateparser = "*"
+more_itertools = "~=7.2"
urllib3 = ">=1.24.2,<1.25"
[dev-packages]
@@ -33,6 +34,7 @@ pre-commit = "~=1.18"
safety = "*"
dodgy = "*"
pytest = "*"
+pytest-cov = "*"
[requires]
python_version = "3.7"
diff --git a/Pipfile.lock b/Pipfile.lock
index e9b64e6cd..9bdcff923 100644
--- a/Pipfile.lock
+++ b/Pipfile.lock
@@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
- "sha256": "a953197b27bbc2af413a1ddd465bb95254167e3a0bba525a3ad34ac738dd6ae4"
+ "sha256": "29aaaa90a070d544e5b39fb6033410daa9bb7f658077205e44099f3175f6822b"
},
"pipfile-spec": 6,
"requires": {
@@ -18,11 +18,11 @@
"default": {
"aio-pika": {
"hashes": [
- "sha256:31e08189841a8350db5bec70608b4d2fbacb89c0a555a18ec47511716a9bfc41",
- "sha256:adfe0acf34356ccd9654a9a1c46f7e8db1dc4497a774c0e54bf2d3af14571bd0"
+ "sha256:29f27a8092169924c9eefb0c5e428d216706618dc9caa75ddb7759638e16cf26",
+ "sha256:4f77ba9b6e7bc27fc88c49638bc3657ae5d4a2539e17fa0c2b25b370547b1b50"
],
"index": "pypi",
- "version": "==6.1.1"
+ "version": "==6.1.2"
},
"aiodns": {
"hashes": [
@@ -34,31 +34,31 @@
},
"aiohttp": {
"hashes": [
- "sha256:0419705a36b43c0ac6f15469f9c2a08cad5c939d78bd12a5c23ea167c8253b2b",
- "sha256:1812fc4bc6ac1bde007daa05d2d0f61199324e0cc893b11523e646595047ca08",
- "sha256:2214b5c0153f45256d5d52d1e0cafe53f9905ed035a142191727a5fb620c03dd",
- "sha256:275909137f0c92c61ba6bb1af856a522d5546f1de8ea01e4e726321c697754ac",
- "sha256:3983611922b561868428ea1e7269e757803713f55b53502423decc509fef1650",
- "sha256:51afec6ffa50a9da4cdef188971a802beb1ca8e8edb40fa429e5e529db3475fa",
- "sha256:589f2ec8a101a0f340453ee6945bdfea8e1cd84c8d88e5be08716c34c0799d95",
- "sha256:789820ddc65e1f5e71516adaca2e9022498fa5a837c79ba9c692a9f8f916c330",
- "sha256:7a968a0bdaaf9abacc260911775611c9a602214a23aeb846f2eb2eeaa350c4dc",
- "sha256:7aeefbed253f59ea39e70c5848de42ed85cb941165357fc7e87ab5d8f1f9592b",
- "sha256:7b2eb55c66512405103485bd7d285a839d53e7fdc261ab20e5bcc51d7aaff5de",
- "sha256:87bc95d3d333bb689c8d755b4a9d7095a2356108002149523dfc8e607d5d32a4",
- "sha256:9d80e40db208e29168d3723d1440ecbb06054d349c5ece6a2c5a611490830dd7",
- "sha256:a1b442195c2a77d33e4dbee67c9877ccbdd3a1f686f91eb479a9577ed8cc326b",
- "sha256:ab3d769413b322d6092f169f316f7b21cd261a7589f7e31db779d5731b0480d8",
- "sha256:b066d3dec5d0f5aee6e34e5765095dc3d6d78ef9839640141a2b20816a0642bd",
- "sha256:b24e7845ae8de3e388ef4bcfcf7f96b05f52c8e633b33cf8003a6b1d726fc7c2",
- "sha256:c59a953c3f8524a7c86eaeaef5bf702555be12f5668f6384149fe4bb75c52698",
- "sha256:cf2cc6c2c10d242790412bea7ccf73726a9a44b4c4b073d2699ef3b48971fd95",
- "sha256:e0c9c8d4150ae904f308ff27b35446990d2b1dfc944702a21925937e937394c6",
- "sha256:f1839db4c2b08a9c8f9788112644f8a8557e8e0ecc77b07091afabb941dc55d0",
- "sha256:f3df52362be39908f9c028a65490fae0475e4898b43a03d8aa29d1e765b45e07"
- ],
- "index": "pypi",
- "version": "==3.4.4"
+ "sha256:00d198585474299c9c3b4f1d5de1a576cc230d562abc5e4a0e81d71a20a6ca55",
+ "sha256:0155af66de8c21b8dba4992aaeeabf55503caefae00067a3b1139f86d0ec50ed",
+ "sha256:09654a9eca62d1bd6d64aa44db2498f60a5c1e0ac4750953fdd79d5c88955e10",
+ "sha256:199f1d106e2b44b6dacdf6f9245493c7d716b01d0b7fbe1959318ba4dc64d1f5",
+ "sha256:296f30dedc9f4b9e7a301e5cc963012264112d78a1d3094cd83ef148fdf33ca1",
+ "sha256:368ed312550bd663ce84dc4b032a962fcb3c7cae099dbbd48663afc305e3b939",
+ "sha256:40d7ea570b88db017c51392349cf99b7aefaaddd19d2c78368aeb0bddde9d390",
+ "sha256:629102a193162e37102c50713e2e31dc9a2fe7ac5e481da83e5bb3c0cee700aa",
+ "sha256:6d5ec9b8948c3d957e75ea14d41e9330e1ac3fed24ec53766c780f82805140dc",
+ "sha256:87331d1d6810214085a50749160196391a712a13336cd02ce1c3ea3d05bcf8d5",
+ "sha256:9a02a04bbe581c8605ac423ba3a74999ec9d8bce7ae37977a3d38680f5780b6d",
+ "sha256:9c4c83f4fa1938377da32bc2d59379025ceeee8e24b89f72fcbccd8ca22dc9bf",
+ "sha256:9cddaff94c0135ee627213ac6ca6d05724bfe6e7a356e5e09ec57bd3249510f6",
+ "sha256:a25237abf327530d9561ef751eef9511ab56fd9431023ca6f4803f1994104d72",
+ "sha256:a5cbd7157b0e383738b8e29d6e556fde8726823dae0e348952a61742b21aeb12",
+ "sha256:a97a516e02b726e089cffcde2eea0d3258450389bbac48cbe89e0f0b6e7b0366",
+ "sha256:acc89b29b5f4e2332d65cd1b7d10c609a75b88ef8925d487a611ca788432dfa4",
+ "sha256:b05bd85cc99b06740aad3629c2585bda7b83bd86e080b44ba47faf905fdf1300",
+ "sha256:c2bec436a2b5dafe5eaeb297c03711074d46b6eb236d002c13c42f25c4a8ce9d",
+ "sha256:cc619d974c8c11fe84527e4b5e1c07238799a8c29ea1c1285149170524ba9303",
+ "sha256:d4392defd4648badaa42b3e101080ae3313e8f4787cb517efd3f5b8157eaefd6",
+ "sha256:e1c3c582ee11af7f63a34a46f0448fca58e59889396ffdae1f482085061a2889"
+ ],
+ "index": "pypi",
+ "version": "==3.5.4"
},
"aiormq": {
"hashes": [
@@ -105,10 +105,10 @@
},
"certifi": {
"hashes": [
- "sha256:046832c04d4e752f37383b628bc601a7ea7211496b4638f6514d0e5b9acc4939",
- "sha256:945e3ba63a0b9f577b1395204e13c3a231f9bc0223888be653286534e5873695"
+ "sha256:e4f3620cfea4f83eedc95b24abd9cd56f3c4b146dd0177e83a21b4eb49e21e50",
+ "sha256:fd7c7c74727ddcf00e9acd26bba8da604ffec95bf1c2144e67aff7a8b50e6cef"
],
- "version": "==2019.6.16"
+ "version": "==2019.9.11"
},
"cffi": {
"hashes": [
@@ -152,11 +152,11 @@
},
"dateparser": {
"hashes": [
- "sha256:42d51be54e74a8e80a4d76d1fa6e4edd997098fce24ad2d94a2eab5ef247193e",
- "sha256:78124c458c461ea7198faa3c038f6381f37588b84bb42740e91a4cbd260b1d09"
+ "sha256:983d84b5e3861cb0aa240cad07f12899bb10b62328aae188b9007e04ce37d665",
+ "sha256:e1eac8ef28de69a554d5fcdb60b172d526d61924b1a40afbbb08df459a36006b"
],
"index": "pypi",
- "version": "==0.7.1"
+ "version": "==0.7.2"
},
"deepdiff": {
"hashes": [
@@ -167,12 +167,11 @@
"version": "==4.0.7"
},
"discord-py": {
- "editable": true,
- "extras": [
- "voice"
+ "hashes": [
+ "sha256:4684733fa137cc7def18087ae935af615212e423e3dbbe3e84ef01d7ae8ed17d"
],
- "git": "https://github.com/Rapptz/discord.py.git",
- "ref": "860d6a9ace8248dfeec18b8b159e7b757d9f56bb"
+ "index": "pypi",
+ "version": "==1.2.3"
},
"docutils": {
"hashes": [
@@ -293,6 +292,14 @@
],
"version": "==1.1.1"
},
+ "more-itertools": {
+ "hashes": [
+ "sha256:409cd48d4db7052af495b09dec721011634af3753ae1ef92d2b32f73a745f832",
+ "sha256:92b8c4b06dac4f0611c0729b2f2ede52b2e1bac1ab48f089c7ddc12e26bb60c4"
+ ],
+ "index": "pypi",
+ "version": "==7.2.0"
+ },
"multidict": {
"hashes": [
"sha256:024b8129695a952ebd93373e45b5d341dbb87c17ce49637b34000093f243dd4f",
@@ -378,42 +385,6 @@
],
"version": "==2.4.2"
},
- "pynacl": {
- "hashes": [
- "sha256:04e30e5bdeeb2d5b34107f28cd2f5bbfdc6c616f3be88fc6f53582ff1669eeca",
- "sha256:0bfa0d94d2be6874e40f896e0a67e290749151e7de767c5aefbad1121cad7512",
- "sha256:11aa4e141b2456ce5cecc19c130e970793fa3a2c2e6fbb8ad65b28f35aa9e6b6",
- "sha256:13bdc1fe084ff9ac7653ae5a924cae03bf4bb07c6667c9eb5b6eb3c570220776",
- "sha256:14339dc233e7a9dda80a3800e64e7ff89d0878ba23360eea24f1af1b13772cac",
- "sha256:1d33e775fab3f383167afb20b9927aaf4961b953d76eeb271a5703a6d756b65b",
- "sha256:2a42b2399d0428619e58dac7734838102d35f6dcdee149e0088823629bf99fbb",
- "sha256:2dce05ac8b3c37b9e2f65eab56c544885607394753e9613fd159d5e2045c2d98",
- "sha256:63cfccdc6217edcaa48369191ae4dca0c390af3c74f23c619e954973035948cd",
- "sha256:6453b0dae593163ffc6db6f9c9c1597d35c650598e2c39c0590d1757207a1ac2",
- "sha256:73a5a96fb5fbf2215beee2353a128d382dbca83f5341f0d3c750877a236569ef",
- "sha256:8abb4ef79161a5f58848b30ab6fb98d8c466da21fdd65558ce1d7afc02c70b5f",
- "sha256:8ac1167195b32a8755de06efd5b2d2fe76fc864517dab66aaf65662cc59e1988",
- "sha256:8f505f42f659012794414fa57c498404e64db78f1d98dfd40e318c569f3c783b",
- "sha256:9c8a06556918ee8e3ab48c65574f318f5a0a4d31437fc135da7ee9d4f9080415",
- "sha256:a1e25fc5650cf64f01c9e435033e53a4aca9de30eb9929d099f3bb078e18f8f2",
- "sha256:be71cd5fce04061e1f3d39597f93619c80cdd3558a6c9ba99a546f144a8d8101",
- "sha256:c5b1a7a680218dee9da0f1b5e24072c46b3c275d35712bc1d505b85bb03441c0",
- "sha256:cb785db1a9468841a1265c9215c60fe5d7af2fb1b209e3316a152704607fc582",
- "sha256:cf6877124ae6a0698404e169b3ba534542cfbc43f939d46b927d956daf0a373a",
- "sha256:d0eb5b2795b7ee2cbcfcadacbe95a13afbda048a262bd369da9904fecb568975",
- "sha256:d3a934e2b9f20abac009d5b6951067cfb5486889cb913192b4d8288b216842f1",
- "sha256:d795f506bcc9463efb5ebb0f65ed77921dcc9e0a50499dedd89f208445de9ecb",
- "sha256:d8aaf7e5d6b0e0ef7d6dbf7abeb75085713d0100b4eb1a4e4e857de76d77ac45",
- "sha256:de2aaca8386cf4d70f1796352f2346f48ddb0bed61dc43a3ce773ba12e064031",
- "sha256:e0d38fa0a75f65f556fb912f2c6790d1fa29b7dd27a1d9cc5591b281321eaaa9",
- "sha256:eb2acabbd487a46b38540a819ef67e477a674481f84a82a7ba2234b9ba46f752",
- "sha256:eeee629828d0eb4f6d98ac41e9a3a6461d114d1d0aa111a8931c049359298da0",
- "sha256:f5836463a3c0cca300295b229b6c7003c415a9d11f8f9288ddbd728e2746524c",
- "sha256:f5ce9e26d25eb0b2d96f3ef0ad70e1d3ae89b5d60255c462252a3e456a48c053",
- "sha256:fabf73d5d0286f9e078774f3435601d2735c94ce9e514ac4fb945701edead7e4"
- ],
- "version": "==1.2.1"
- },
"pyparsing": {
"hashes": [
"sha256:6f98a7b9397e206d78cc01df10131398f1c8b8510a2f4d97d9abd82e1aacdd80",
@@ -637,10 +608,10 @@
},
"certifi": {
"hashes": [
- "sha256:046832c04d4e752f37383b628bc601a7ea7211496b4638f6514d0e5b9acc4939",
- "sha256:945e3ba63a0b9f577b1395204e13c3a231f9bc0223888be653286534e5873695"
+ "sha256:e4f3620cfea4f83eedc95b24abd9cd56f3c4b146dd0177e83a21b4eb49e21e50",
+ "sha256:fd7c7c74727ddcf00e9acd26bba8da604ffec95bf1c2144e67aff7a8b50e6cef"
],
- "version": "==2019.6.16"
+ "version": "==2019.9.11"
},
"cfgv": {
"hashes": [
@@ -663,6 +634,43 @@
],
"version": "==7.0"
},
+ "coverage": {
+ "hashes": [
+ "sha256:08907593569fe59baca0bf152c43f3863201efb6113ecb38ce7e97ce339805a6",
+ "sha256:0be0f1ed45fc0c185cfd4ecc19a1d6532d72f86a2bac9de7e24541febad72650",
+ "sha256:141f08ed3c4b1847015e2cd62ec06d35e67a3ac185c26f7635f4406b90afa9c5",
+ "sha256:19e4df788a0581238e9390c85a7a09af39c7b539b29f25c89209e6c3e371270d",
+ "sha256:23cc09ed395b03424d1ae30dcc292615c1372bfba7141eb85e11e50efaa6b351",
+ "sha256:245388cda02af78276b479f299bbf3783ef0a6a6273037d7c60dc73b8d8d7755",
+ "sha256:331cb5115673a20fb131dadd22f5bcaf7677ef758741312bee4937d71a14b2ef",
+ "sha256:386e2e4090f0bc5df274e720105c342263423e77ee8826002dcffe0c9533dbca",
+ "sha256:3a794ce50daee01c74a494919d5ebdc23d58873747fa0e288318728533a3e1ca",
+ "sha256:60851187677b24c6085248f0a0b9b98d49cba7ecc7ec60ba6b9d2e5574ac1ee9",
+ "sha256:63a9a5fc43b58735f65ed63d2cf43508f462dc49857da70b8980ad78d41d52fc",
+ "sha256:6b62544bb68106e3f00b21c8930e83e584fdca005d4fffd29bb39fb3ffa03cb5",
+ "sha256:6ba744056423ef8d450cf627289166da65903885272055fb4b5e113137cfa14f",
+ "sha256:7494b0b0274c5072bddbfd5b4a6c6f18fbbe1ab1d22a41e99cd2d00c8f96ecfe",
+ "sha256:826f32b9547c8091679ff292a82aca9c7b9650f9fda3e2ca6bf2ac905b7ce888",
+ "sha256:93715dffbcd0678057f947f496484e906bf9509f5c1c38fc9ba3922893cda5f5",
+ "sha256:9a334d6c83dfeadae576b4d633a71620d40d1c379129d587faa42ee3e2a85cce",
+ "sha256:af7ed8a8aa6957aac47b4268631fa1df984643f07ef00acd374e456364b373f5",
+ "sha256:bf0a7aed7f5521c7ca67febd57db473af4762b9622254291fbcbb8cd0ba5e33e",
+ "sha256:bf1ef9eb901113a9805287e090452c05547578eaab1b62e4ad456fcc049a9b7e",
+ "sha256:c0afd27bc0e307a1ffc04ca5ec010a290e49e3afbe841c5cafc5c5a80ecd81c9",
+ "sha256:dd579709a87092c6dbee09d1b7cfa81831040705ffa12a1b248935274aee0437",
+ "sha256:df6712284b2e44a065097846488f66840445eb987eb81b3cc6e4149e7b6982e1",
+ "sha256:e07d9f1a23e9e93ab5c62902833bf3e4b1f65502927379148b6622686223125c",
+ "sha256:e2ede7c1d45e65e209d6093b762e98e8318ddeff95317d07a27a2140b80cfd24",
+ "sha256:e4ef9c164eb55123c62411f5936b5c2e521b12356037b6e1c2617cef45523d47",
+ "sha256:eca2b7343524e7ba246cab8ff00cab47a2d6d54ada3b02772e908a45675722e2",
+ "sha256:eee64c616adeff7db37cc37da4180a3a5b6177f5c46b187894e633f088fb5b28",
+ "sha256:ef824cad1f980d27f26166f86856efe11eff9912c4fed97d3804820d43fa550c",
+ "sha256:efc89291bd5a08855829a3c522df16d856455297cf35ae827a37edac45f466a7",
+ "sha256:fa964bae817babece5aa2e8c1af841bebb6d0b9add8e637548809d040443fee0",
+ "sha256:ff37757e068ae606659c28c3bd0d923f9d29a85de79bf25b2b34b148473b5025"
+ ],
+ "version": "==4.5.4"
+ },
"dodgy": {
"hashes": [
"sha256:65e13cf878d7aff129f1461c13cb5fd1bb6dfe66bb5327e09379c3877763280c"
@@ -763,11 +771,11 @@
},
"importlib-metadata": {
"hashes": [
- "sha256:9ff1b1c5a354142de080b8a4e9803e5d0d59283c93aed808617c787d16768375",
- "sha256:b7143592e374e50584564794fcb8aaf00a23025f9db866627f89a21491847a8d"
+ "sha256:aa18d7378b00b40847790e7c27e11673d7fed219354109d0e7b9e5b25dc3ad26",
+ "sha256:d5f18a79777f3aa179c145737780282e27b508fc8fd688cb17c7a813e8bd39af"
],
"markers": "python_version < '3.8'",
- "version": "==0.20"
+ "version": "==0.23"
},
"mccabe": {
"hashes": [
@@ -781,6 +789,7 @@
"sha256:409cd48d4db7052af495b09dec721011634af3753ae1ef92d2b32f73a745f832",
"sha256:92b8c4b06dac4f0611c0729b2f2ede52b2e1bac1ab48f089c7ddc12e26bb60c4"
],
+ "index": "pypi",
"version": "==7.2.0"
},
"nodeenv": {
@@ -798,10 +807,10 @@
},
"pluggy": {
"hashes": [
- "sha256:0825a152ac059776623854c1543d65a4ad408eb3d33ee114dff91e57ec6ae6fc",
- "sha256:b9817417e95936bf75d85d3f8767f7df6cdde751fc40aed3bb3074cbcb77757c"
+ "sha256:0db4b7601aae1d35b4a033282da476845aa19185c1e6964b25cf324b5e4ec3e6",
+ "sha256:fa5fa1622fa6dd5c030e9cad086fa19ef6a0cf6d7a2d12318e10cb49d6d68f34"
],
- "version": "==0.12.0"
+ "version": "==0.13.0"
},
"pre-commit": {
"hashes": [
@@ -854,6 +863,14 @@
"index": "pypi",
"version": "==5.1.2"
},
+ "pytest-cov": {
+ "hashes": [
+ "sha256:2b097cde81a302e1047331b48cadacf23577e431b61e9c6f49a1170bbe3d3da6",
+ "sha256:e00ea4fdde970725482f1f35630d12f074e121a23801aabf2ae154ec6bdd343a"
+ ],
+ "index": "pypi",
+ "version": "==2.7.1"
+ },
"pyyaml": {
"hashes": [
"sha256:0113bc0ec2ad727182326b61326afa3d1d8280ae1122493553fd6f4397f33df9",
diff --git a/azure-pipelines.yml b/azure-pipelines.yml
index 19df35c11..4dcad685c 100644
--- a/azure-pipelines.yml
+++ b/azure-pipelines.yml
@@ -38,9 +38,23 @@ jobs:
- script: python -m flake8
displayName: 'Run linter'
- - script: BOT_TOKEN=foobar python -m pytest tests
+ - script: BOT_API_KEY=foo BOT_TOKEN=bar WOLFRAM_API_KEY=baz python -m pytest --junitxml=junit.xml --cov=bot --cov-branch --cov-report=term --cov-report=xml tests
displayName: Run tests
+ - task: PublishCodeCoverageResults@1
+ displayName: 'Publish Coverage Results'
+ condition: succeededOrFailed()
+ inputs:
+ codeCoverageTool: Cobertura
+ summaryFileLocation: coverage.xml
+
+ - task: PublishTestResults@2
+ displayName: 'Publish Test Results'
+ condition: succeededOrFailed()
+ inputs:
+ testResultsFiles: junit.xml
+ testRunTitle: 'Bot Test results'
+
- job: build
displayName: 'Build Containers'
dependsOn: 'test'
diff --git a/bot/__main__.py b/bot/__main__.py
index e12508e6d..f25693734 100644
--- a/bot/__main__.py
+++ b/bot/__main__.py
@@ -2,10 +2,11 @@ import asyncio
import logging
import socket
+import discord
from aiohttp import AsyncResolver, ClientSession, TCPConnector
-from discord import Game
from discord.ext.commands import Bot, when_mentioned_or
+from bot import patches
from bot.api import APIClient, APILoggingHandler
from bot.constants import Bot as BotConfig, DEBUG_MODE
@@ -14,9 +15,9 @@ log = logging.getLogger('bot')
bot = Bot(
command_prefix=when_mentioned_or(BotConfig.prefix),
- activity=Game(name="Commands: !help"),
+ activity=discord.Game(name="Commands: !help"),
case_insensitive=True,
- max_messages=10_000
+ max_messages=10_000,
)
# Global aiohttp session for all cogs
@@ -53,7 +54,6 @@ if not DEBUG_MODE:
# Feature cogs
bot.load_extension("bot.cogs.alias")
bot.load_extension("bot.cogs.defcon")
-bot.load_extension("bot.cogs.deployment")
bot.load_extension("bot.cogs.eval")
bot.load_extension("bot.cogs.free")
bot.load_extension("bot.cogs.information")
@@ -72,6 +72,11 @@ bot.load_extension("bot.cogs.utils")
bot.load_extension("bot.cogs.watchchannels")
bot.load_extension("bot.cogs.wolfram")
+# Apply `message_edited_at` patch if discord.py did not yet release a bug fix.
+if not hasattr(discord.message.Message, '_handle_edited_timestamp'):
+ patches.message_edited_at.apply_patch()
+
bot.run(BotConfig.token)
-bot.http_session.close() # Close the aiohttp session when the bot finishes running
+# This calls a coroutine, so it doesn't do anything at the moment.
+# bot.http_session.close() # Close the aiohttp session when the bot finishes running
diff --git a/bot/api.py b/bot/api.py
index b714bda24..5ab554052 100644
--- a/bot/api.py
+++ b/bot/api.py
@@ -1,6 +1,6 @@
import asyncio
import logging
-from typing import Union
+from typing import Optional
from urllib.parse import quote as quote_url
import aiohttp
@@ -11,11 +11,23 @@ log = logging.getLogger(__name__)
class ResponseCodeError(ValueError):
- """Represent a not-OK response code."""
-
- def __init__(self, response: aiohttp.ClientResponse):
+ """Exception representing a non-OK response code."""
+
+ def __init__(
+ self,
+ response: aiohttp.ClientResponse,
+ response_json: Optional[dict] = None,
+ response_text: str = ""
+ ):
+ self.status = response.status
+ self.response_json = response_json or {}
+ self.response_text = response_text
self.response = response
+ def __str__(self):
+ response = self.response_json if self.response_json else self.response_text
+ return f"Status: {self.status} Response: {response}"
+
class APIClient:
"""Django Site API wrapper."""
@@ -36,42 +48,47 @@ class APIClient:
def _url_for(endpoint: str) -> str:
return f"{URLs.site_schema}{URLs.site_api}/{quote_url(endpoint)}"
- def maybe_raise_for_status(self, response: aiohttp.ClientResponse, should_raise: bool) -> None:
+ async def maybe_raise_for_status(self, response: aiohttp.ClientResponse, should_raise: bool) -> None:
"""Raise ResponseCodeError for non-OK response if an exception should be raised."""
if should_raise and response.status >= 400:
- raise ResponseCodeError(response=response)
+ try:
+ response_json = await response.json()
+ raise ResponseCodeError(response=response, response_json=response_json)
+ except aiohttp.ContentTypeError:
+ response_text = await response.text()
+ raise ResponseCodeError(response=response, response_text=response_text)
async def get(self, endpoint: str, *args, raise_for_status: bool = True, **kwargs) -> dict:
"""Site API GET."""
async with self.session.get(self._url_for(endpoint), *args, **kwargs) as resp:
- self.maybe_raise_for_status(resp, raise_for_status)
+ await self.maybe_raise_for_status(resp, raise_for_status)
return await resp.json()
async def patch(self, endpoint: str, *args, raise_for_status: bool = True, **kwargs) -> dict:
"""Site API PATCH."""
async with self.session.patch(self._url_for(endpoint), *args, **kwargs) as resp:
- self.maybe_raise_for_status(resp, raise_for_status)
+ await self.maybe_raise_for_status(resp, raise_for_status)
return await resp.json()
async def post(self, endpoint: str, *args, raise_for_status: bool = True, **kwargs) -> dict:
"""Site API POST."""
async with self.session.post(self._url_for(endpoint), *args, **kwargs) as resp:
- self.maybe_raise_for_status(resp, raise_for_status)
+ await self.maybe_raise_for_status(resp, raise_for_status)
return await resp.json()
async def put(self, endpoint: str, *args, raise_for_status: bool = True, **kwargs) -> dict:
"""Site API PUT."""
async with self.session.put(self._url_for(endpoint), *args, **kwargs) as resp:
- self.maybe_raise_for_status(resp, raise_for_status)
+ await self.maybe_raise_for_status(resp, raise_for_status)
return await resp.json()
- async def delete(self, endpoint: str, *args, raise_for_status: bool = True, **kwargs) -> Union[dict, None]:
+ async def delete(self, endpoint: str, *args, raise_for_status: bool = True, **kwargs) -> Optional[dict]:
"""Site API DELETE."""
async with self.session.delete(self._url_for(endpoint), *args, **kwargs) as resp:
if resp.status == 204:
return None
- self.maybe_raise_for_status(resp, raise_for_status)
+ await self.maybe_raise_for_status(resp, raise_for_status)
return await resp.json()
@@ -133,7 +150,7 @@ class APILoggingHandler(logging.StreamHandler):
# 1. Do not log anything below `DEBUG`. This is only applicable
# for the monkeypatched `TRACE` logging level, which has a
# lower numeric value than `DEBUG`.
- record.levelno > logging.DEBUG
+ record.levelno >= logging.DEBUG
# 2. Ignore logging messages which are sent by this logging
# handler itself. This is required because if we were to
# not ignore messages emitted by this handler, we would
diff --git a/bot/cogs/alias.py b/bot/cogs/alias.py
index a01a05715..80ff37983 100644
--- a/bot/cogs/alias.py
+++ b/bot/cogs/alias.py
@@ -3,9 +3,7 @@ import logging
from typing import Union
from discord import Colour, Embed, Member, User
-from discord.ext.commands import (
- Bot, Command, Context, clean_content, command, group
-)
+from discord.ext.commands import Bot, Cog, Command, Context, clean_content, command, group
from bot.cogs.watchchannels.watchchannel import proxy_user
from bot.converters import TagNameConverter
@@ -14,7 +12,7 @@ from bot.pagination import LinePaginator
log = logging.getLogger(__name__)
-class Alias:
+class Alias (Cog):
"""Aliases for commonly used commands."""
def __init__(self, bot: Bot):
@@ -98,7 +96,7 @@ class Alias:
@command(name="exception", hidden=True)
async def tags_get_traceback_alias(self, ctx: Context) -> None:
"""Alias for invoking <prefix>tags get traceback."""
- await self.invoke(ctx, "tags get traceback")
+ await self.invoke(ctx, "tags get", tag_name="traceback")
@group(name="get",
aliases=("show", "g"),
@@ -112,8 +110,12 @@ class Alias:
async def tags_get_alias(
self, ctx: Context, *, tag_name: TagNameConverter = None
) -> None:
- """Alias for invoking <prefix>tags get [tag_name]."""
- await self.invoke(ctx, "tags get", tag_name)
+ """
+ Alias for invoking <prefix>tags get [tag_name].
+
+ tag_name: str - tag to be viewed.
+ """
+ await self.invoke(ctx, "tags get", tag_name=tag_name)
@get_group_alias.command(name="docs", aliases=("doc", "d"), hidden=True)
async def docs_get_alias(
@@ -132,6 +134,11 @@ class Alias:
"""Alias for invoking <prefix>nomination end [user] [reason]."""
await self.invoke(ctx, "nomination end", user, reason=reason)
+ @command(name="nominees", hidden=True)
+ async def nominees_alias(self, ctx: Context) -> None:
+ """Alias for invoking <prefix>tp watched."""
+ await self.invoke(ctx, "talentpool watched")
+
def setup(bot: Bot) -> None:
"""Alias cog load."""
diff --git a/bot/cogs/antispam.py b/bot/cogs/antispam.py
index c7f6503a8..7a3360436 100644
--- a/bot/cogs/antispam.py
+++ b/bot/cogs/antispam.py
@@ -1,19 +1,23 @@
+import asyncio
import logging
+from collections.abc import Mapping
+from dataclasses import dataclass, field
from datetime import datetime, timedelta
-from typing import List
+from operator import itemgetter
+from typing import Dict, Iterable, List, Set
-from discord import Colour, Member, Message, Object, TextChannel
-from discord.ext.commands import Bot
+from discord import Colour, Member, Message, NotFound, Object, TextChannel
+from discord.ext.commands import Bot, Cog
from bot import rules
-from bot.cogs.moderation import Moderation
from bot.cogs.modlog import ModLog
from bot.constants import (
AntiSpam as AntiSpamConfig, Channels,
Colours, DEBUG_MODE, Event, Filter,
Guild as GuildConfig, Icons,
- Roles, STAFF_ROLES,
+ STAFF_ROLES,
)
+from bot.converters import ExpirationDate
log = logging.getLogger(__name__)
@@ -32,25 +36,104 @@ RULE_FUNCTION_MAPPING = {
}
-class AntiSpam:
- """Spam detection & mitigation measures."""
+@dataclass
+class DeletionContext:
+ """Represents a Deletion Context for a single spam event."""
- def __init__(self, bot: Bot):
+ channel: TextChannel
+ members: Dict[int, Member] = field(default_factory=dict)
+ rules: Set[str] = field(default_factory=set)
+ messages: Dict[int, Message] = field(default_factory=dict)
+
+ def add(self, rule_name: str, members: Iterable[Member], messages: Iterable[Message]) -> None:
+ """Adds new rule violation events to the deletion context."""
+ self.rules.add(rule_name)
+
+ for member in members:
+ if member.id not in self.members:
+ self.members[member.id] = member
+
+ for message in messages:
+ if message.id not in self.messages:
+ self.messages[message.id] = message
+
+ async def upload_messages(self, actor_id: int, modlog: ModLog) -> None:
+ """Method that takes care of uploading the queue and posting modlog alert."""
+ triggered_by_users = ", ".join(f"{m.display_name}#{m.discriminator} (`{m.id}`)" for m in self.members.values())
+
+ mod_alert_message = (
+ f"**Triggered by:** {triggered_by_users}\n"
+ f"**Channel:** {self.channel.mention}\n"
+ f"**Rules:** {', '.join(rule for rule in self.rules)}\n"
+ )
+
+ # For multiple messages or those with excessive newlines, use the logs API
+ if len(self.messages) > 1 or 'newlines' in self.rules:
+ url = await modlog.upload_log(self.messages.values(), actor_id)
+ mod_alert_message += f"A complete log of the offending messages can be found [here]({url})"
+ else:
+ mod_alert_message += "Message:\n"
+ [message] = self.messages.values()
+ content = message.clean_content
+ remaining_chars = 2040 - len(mod_alert_message)
+
+ if len(content) > remaining_chars:
+ content = content[:remaining_chars] + "..."
+
+ mod_alert_message += f"{content}"
+
+ *_, last_message = self.messages.values()
+ await modlog.send_log_message(
+ icon_url=Icons.filtering,
+ colour=Colour(Colours.soft_red),
+ title=f"Spam detected!",
+ text=mod_alert_message,
+ thumbnail=last_message.author.avatar_url_as(static_format="png"),
+ channel_id=Channels.mod_alerts,
+ ping_everyone=AntiSpamConfig.ping_everyone
+ )
+
+
+class AntiSpam(Cog):
+ """Cog that controls our anti-spam measures."""
+
+ def __init__(self, bot: Bot, validation_errors: bool) -> None:
self.bot = bot
- self._muted_role = Object(Roles.muted)
+ self.validation_errors = validation_errors
+ role_id = AntiSpamConfig.punishment['role_id']
+ self.muted_role = Object(role_id)
+ self.expiration_date_converter = ExpirationDate()
+
+ self.message_deletion_queue = dict()
+ self.queue_consumption_tasks = dict()
@property
def mod_log(self) -> ModLog:
- """Get currently loaded ModLog cog instance."""
+ """Allows for easy access of the ModLog cog."""
return self.bot.get_cog("ModLog")
+ @Cog.listener()
async def on_ready(self) -> None:
- """Instantiate punishment role."""
- role_id = AntiSpamConfig.punishment['role_id']
- self.muted_role = Object(role_id)
+ """Unloads the cog and alerts admins if configuration validation failed."""
+ if self.validation_errors:
+ body = "**The following errors were encountered:**\n"
+ body += "\n".join(f"- {error}" for error in self.validation_errors.values())
+ body += "\n\n**The cog has been unloaded.**"
+
+ await self.mod_log.send_log_message(
+ title=f"Error: AntiSpam configuration validation failed!",
+ text=body,
+ ping_everyone=True,
+ icon_url=Icons.token_removed,
+ colour=Colour.red()
+ )
+
+ self.bot.remove_cog(self.__class__.__name__)
+ return
+ @Cog.listener()
async def on_message(self, message: Message) -> None:
- """Monitor incoming messages & match against spam criteria for possible filtering."""
+ """Applies the antispam rules to each received message."""
if (
not message.guild
or message.guild.id != GuildConfig.id
@@ -63,14 +146,15 @@ class AntiSpam:
# Fetch the rule configuration with the highest rule interval.
max_interval_config = max(
AntiSpamConfig.rules.values(),
- key=lambda config: config['interval']
+ key=itemgetter('interval')
)
max_interval = max_interval_config['interval']
# Store history messages since `interval` seconds ago in a list to prevent unnecessary API calls.
earliest_relevant_at = datetime.utcnow() - timedelta(seconds=max_interval)
relevant_messages = [
- msg async for msg in message.channel.history(after=earliest_relevant_at, reverse=False)
+ msg async for msg in message.channel.history(after=earliest_relevant_at, oldest_first=False)
+ if not msg.author.bot
]
for rule_name in AntiSpamConfig.rules:
@@ -91,62 +175,53 @@ class AntiSpam:
if result is not None:
reason, members, relevant_messages = result
full_reason = f"`{rule_name}` rule: {reason}"
+
+ # If there's no spam event going on for this channel, start a new Message Deletion Context
+ if message.channel.id not in self.message_deletion_queue:
+ log.trace(f"Creating queue for channel `{message.channel.id}`")
+ self.message_deletion_queue[message.channel.id] = DeletionContext(channel=message.channel)
+ self.queue_consumption_tasks = self.bot.loop.create_task(
+ self._process_deletion_context(message.channel.id)
+ )
+
+ # Add the relevant of this trigger to the Deletion Context
+ self.message_deletion_queue[message.channel.id].add(
+ rule_name=rule_name,
+ members=members,
+ messages=relevant_messages
+ )
+
for member in members:
# Fire it off as a background task to ensure
# that the sleep doesn't block further tasks
self.bot.loop.create_task(
- self.punish(message, member, full_reason, relevant_messages, rule_name)
+ self.punish(message, member, full_reason)
)
await self.maybe_delete_messages(message.channel, relevant_messages)
break
- async def punish(self, msg: Message, member: Member, reason: str, messages: List[Message], rule_name: str) -> None:
- """Deal out punishment for author(s) of messages that meet our spam criteria."""
- # Sanity check to ensure we're not lagging behind
- if self.muted_role not in member.roles:
+ async def punish(self, msg: Message, member: Member, reason: str) -> None:
+ """Punishes the given member for triggering an antispam rule."""
+ if not any(role.id == self.muted_role.id for role in member.roles):
remove_role_after = AntiSpamConfig.punishment['remove_after']
- mod_alert_message = (
- f"**Triggered by:** {member.display_name}#{member.discriminator} (`{member.id}`)\n"
- f"**Channel:** {msg.channel.mention}\n"
- f"**Reason:** {reason}\n"
- )
+ # We need context, let's get it
+ context = await self.bot.get_context(msg)
- # For multiple messages or those with excessive newlines, use the logs API
- if len(messages) > 1 or rule_name == 'newlines':
- url = await self.mod_log.upload_log(messages, msg.guild.me.id)
- mod_alert_message += f"A complete log of the offending messages can be found [here]({url})"
- else:
- mod_alert_message += "Message:\n"
- content = messages[0].clean_content
- remaining_chars = 2040 - len(mod_alert_message)
-
- if len(content) > remaining_chars:
- content = content[:remaining_chars] + "..."
-
- mod_alert_message += f"{content}"
-
- # Return the mod log message Context that we can use to post the infraction
- mod_log_ctx = await self.mod_log.send_log_message(
- icon_url=Icons.filtering,
- colour=Colour(Colours.soft_red),
- title=f"Spam detected!",
- text=mod_alert_message,
- thumbnail=msg.author.avatar_url_as(static_format="png"),
- channel_id=Channels.mod_alerts,
- ping_everyone=AntiSpamConfig.ping_everyone
+ # Since we're going to invoke the tempmute command directly, we need to manually call the converter.
+ dt_remove_role_after = await self.expiration_date_converter.convert(context, f"{remove_role_after}S")
+ await context.invoke(
+ self.bot.get_command('tempmute'),
+ member,
+ dt_remove_role_after,
+ reason=reason
)
- # Run a tempmute
- await mod_log_ctx.invoke(Moderation.tempmute, member, f"{remove_role_after}S", reason=reason)
-
async def maybe_delete_messages(self, channel: TextChannel, messages: List[Message]) -> None:
- """Determine whether flagged messages should be disabled & delete them if so."""
- # Is deletion of offending messages actually enabled?
+ """Cleans the messages if cleaning is configured."""
if AntiSpamConfig.clean_offending:
-
# If we have more than one message, we can use bulk delete.
if len(messages) > 1:
message_ids = [message.id for message in messages]
@@ -157,28 +232,47 @@ class AntiSpam:
# Delete the message directly instead.
else:
self.mod_log.ignore(Event.message_delete, messages[0].id)
- await messages[0].delete()
+ try:
+ await messages[0].delete()
+ except NotFound:
+ log.info(f"Tried to delete message `{messages[0].id}`, but message could not be found.")
+
+ async def _process_deletion_context(self, context_id: int) -> None:
+ """Processes the Deletion Context queue."""
+ log.trace("Sleeping before processing message deletion queue.")
+ await asyncio.sleep(10)
+
+ if context_id not in self.message_deletion_queue:
+ log.error(f"Started processing deletion queue for context `{context_id}`, but it was not found!")
+ return
+
+ deletion_context = self.message_deletion_queue.pop(context_id)
+ await deletion_context.upload_messages(self.bot.user.id, self.mod_log)
-def validate_config() -> None:
- """Validate loaded antispam filter configuration(s)."""
- for name, config in AntiSpamConfig.rules.items():
+def validate_config(rules: Mapping = AntiSpamConfig.rules) -> Dict[str, str]:
+ """Validates the antispam configs."""
+ validation_errors = {}
+ for name, config in rules.items():
if name not in RULE_FUNCTION_MAPPING:
- raise ValueError(
+ log.error(
f"Unrecognized antispam rule `{name}`. "
f"Valid rules are: {', '.join(RULE_FUNCTION_MAPPING)}"
)
-
+ validation_errors[name] = f"`{name}` is not recognized as an antispam rule."
+ continue
for required_key in ('interval', 'max'):
if required_key not in config:
- raise ValueError(
+ log.error(
f"`{required_key}` is required but was not "
f"set in rule `{name}`'s configuration."
)
+ validation_errors[name] = f"Key `{required_key}` is required but not set for rule `{name}`"
+ return validation_errors
def setup(bot: Bot) -> None:
"""Antispam cog load."""
- validate_config()
- bot.add_cog(AntiSpam(bot))
+ validation_errors = validate_config()
+ bot.add_cog(AntiSpam(bot, validation_errors))
log.info("Cog loaded: AntiSpam")
diff --git a/bot/cogs/bot.py b/bot/cogs/bot.py
index be7922ef9..585be5a42 100644
--- a/bot/cogs/bot.py
+++ b/bot/cogs/bot.py
@@ -5,19 +5,18 @@ import time
from typing import Optional, Tuple, Union
from discord import Embed, Message, RawMessageUpdateEvent
-from discord.ext.commands import Bot, Context, command, group
+from discord.ext.commands import Bot, Cog, Context, command, group
-from bot.constants import (
- Channels, Guild, MODERATION_ROLES,
- Roles, URLs,
-)
+from bot.constants import Channels, DEBUG_MODE, Guild, MODERATION_ROLES, Roles, URLs
from bot.decorators import with_role
from bot.utils.messages import wait_for_deletion
log = logging.getLogger(__name__)
+RE_MARKDOWN = re.compile(r'([*_~`|>])')
-class Bot:
+
+class Bot(Cog):
"""Bot information commands."""
def __init__(self, bot: Bot):
@@ -31,6 +30,8 @@ class Bot:
Channels.help_3: 0,
Channels.help_4: 0,
Channels.help_5: 0,
+ Channels.help_6: 0,
+ Channels.help_7: 0,
Channels.python: 0,
}
@@ -45,23 +46,23 @@ class Bot:
@group(invoke_without_command=True, name="bot", hidden=True)
@with_role(Roles.verified)
- async def bot_group(self, ctx: Context) -> None:
+ async def botinfo_group(self, ctx: Context) -> None:
"""Bot informational commands."""
await ctx.invoke(self.bot.get_command("help"), "bot")
- @bot_group.command(name='about', aliases=('info',), hidden=True)
+ @botinfo_group.command(name='about', aliases=('info',), hidden=True)
@with_role(Roles.verified)
async def about_command(self, ctx: Context) -> None:
"""Get information about the bot."""
embed = Embed(
description="A utility bot designed just for the Python server! Try `!help` for more info.",
- url="https://gitlab.com/discord-python/projects/bot"
+ url="https://github.com/python-discord/bot"
)
embed.add_field(name="Total Users", value=str(len(self.bot.get_guild(Guild.id).members)))
embed.set_author(
name="Python Bot",
- url="https://gitlab.com/discord-python/projects/bot",
+ url="https://github.com/python-discord/bot",
icon_url=URLs.bot_avatar
)
@@ -219,6 +220,7 @@ class Bot:
return msg.content[:3] in not_backticks
+ @Cog.listener()
async def on_message(self, msg: Message) -> None:
"""
Detect poorly formatted Python code in new messages.
@@ -237,7 +239,7 @@ class Bot:
if parse_codeblock:
on_cooldown = (time.time() - self.channel_cooldowns.get(msg.channel.id, 0)) < 300
- if not on_cooldown:
+ if not on_cooldown or DEBUG_MODE:
try:
if self.has_bad_ticks(msg):
ticks = msg.content[:3]
@@ -262,13 +264,14 @@ class Bot:
current_length += len(line)
lines_walked += 1
content = content[:current_length] + "#..."
-
+ content_escaped_markdown = RE_MARKDOWN.sub(r'\\\1', content)
howto = (
"It looks like you are trying to paste code into this channel.\n\n"
"You seem to be using the wrong symbols to indicate where the codeblock should start. "
f"The correct symbols would be \\`\\`\\`, not `{ticks}`.\n\n"
"**Here is an example of how it should look:**\n"
- f"\\`\\`\\`python\n{content}\n\\`\\`\\`\n\n**This will result in the following:**\n"
+ f"\\`\\`\\`python\n{content_escaped_markdown}\n\\`\\`\\`\n\n"
+ "**This will result in the following:**\n"
f"```python\n{content}\n```"
)
@@ -304,13 +307,15 @@ class Bot:
lines_walked += 1
content = content[:current_length] + "#..."
+ content_escaped_markdown = RE_MARKDOWN.sub(r'\\\1', content)
howto += (
"It looks like you're trying to paste code into this channel.\n\n"
"Discord has support for Markdown, which allows you to post code with full "
"syntax highlighting. Please use these whenever you paste code, as this "
"helps improve the legibility and makes it easier for us to help you.\n\n"
f"**To do this, use the following method:**\n"
- f"\\`\\`\\`python\n{content}\n\\`\\`\\`\n\n**This will result in the following:**\n"
+ f"\\`\\`\\`python\n{content_escaped_markdown}\n\\`\\`\\`\n\n"
+ "**This will result in the following:**\n"
f"```python\n{content}\n```"
)
@@ -340,6 +345,7 @@ class Bot:
f"The message that was posted was:\n\n{msg.content}\n\n"
)
+ @Cog.listener()
async def on_raw_message_edit(self, payload: RawMessageUpdateEvent) -> None:
"""Check to see if an edited message (previously called out) still contains poorly formatted code."""
if (
@@ -354,14 +360,14 @@ class Bot:
# Retrieve channel and message objects for use later
channel = self.bot.get_channel(int(payload.data.get("channel_id")))
- user_message = await channel.get_message(payload.message_id)
+ user_message = await channel.fetch_message(payload.message_id)
# Checks to see if the user has corrected their codeblock. If it's fixed, has_fixed_codeblock will be None
has_fixed_codeblock = self.codeblock_stripping(payload.data.get("content"), self.has_bad_ticks(user_message))
# If the message is fixed, delete the bot message and the entry from the id dictionary
if has_fixed_codeblock is None:
- bot_message = await channel.get_message(self.codeblock_message_ids[payload.message_id])
+ bot_message = await channel.fetch_message(self.codeblock_message_ids[payload.message_id])
await bot_message.delete()
del self.codeblock_message_ids[payload.message_id]
log.trace("User's incorrect code block has been fixed. Removing bot formatting message.")
diff --git a/bot/cogs/clean.py b/bot/cogs/clean.py
index 2c889c9f2..da1ae8b9b 100644
--- a/bot/cogs/clean.py
+++ b/bot/cogs/clean.py
@@ -4,7 +4,7 @@ import re
from typing import Optional
from discord import Colour, Embed, Message, User
-from discord.ext.commands import Bot, Context, group
+from discord.ext.commands import Bot, Cog, Context, group
from bot.cogs.modlog import ModLog
from bot.constants import (
@@ -16,7 +16,7 @@ from bot.decorators import with_role
log = logging.getLogger(__name__)
-class Clean:
+class Clean(Cog):
"""
A cog that allows messages to be deleted in bulk, while applying various filters.
@@ -110,7 +110,8 @@ class Clean:
self.cleaning = True
invocation_deleted = False
- async for message in ctx.channel.history(limit=amount):
+ # To account for the invocation message, we index `amount + 1` messages.
+ async for message in ctx.channel.history(limit=amount + 1):
# If at any point the cancel command is invoked, we should stop.
if not self.cleaning:
diff --git a/bot/cogs/cogs.py b/bot/cogs/cogs.py
index e6fa92927..fcb987a07 100644
--- a/bot/cogs/cogs.py
+++ b/bot/cogs/cogs.py
@@ -2,7 +2,7 @@ import logging
import os
from discord import Colour, Embed
-from discord.ext.commands import Bot, Context, group
+from discord.ext.commands import Bot, Cog, Context, group
from bot.constants import (
Emojis, MODERATION_ROLES, Roles, URLs
@@ -15,7 +15,7 @@ log = logging.getLogger(__name__)
KEEP_LOADED = ["bot.cogs.cogs", "bot.cogs.modlog"]
-class Cogs:
+class Cogs(Cog):
"""Cog management commands."""
def __init__(self, bot: Bot):
@@ -35,13 +35,13 @@ class Cogs:
self.cogs.update({v: k for k, v in self.cogs.items()})
@group(name='cogs', aliases=('c',), invoke_without_command=True)
- @with_role(*MODERATION_ROLES, Roles.devops)
+ @with_role(*MODERATION_ROLES, Roles.core_developer)
async def cogs_group(self, ctx: Context) -> None:
"""Load, unload, reload, and list active cogs."""
await ctx.invoke(self.bot.get_command("help"), "cogs")
@cogs_group.command(name='load', aliases=('l',))
- @with_role(*MODERATION_ROLES, Roles.devops)
+ @with_role(*MODERATION_ROLES, Roles.core_developer)
async def load_command(self, ctx: Context, cog: str) -> None:
"""
Load up an unloaded cog, given the module containing it.
@@ -56,7 +56,7 @@ class Cogs:
embed.set_author(
name="Python Bot (Cogs)",
- url=URLs.gitlab_bot_repo,
+ url=URLs.github_bot_repo,
icon_url=URLs.bot_avatar
)
@@ -80,8 +80,8 @@ class Cogs:
except Exception as e:
log.error(f"{ctx.author} requested we load the '{cog}' cog, "
"but the loading failed with the following error: \n"
- f"{e}")
- embed.description = f"Failed to load cog: {cog}\n\n```{e}```"
+ f"**{e.__class__.__name__}: {e}**")
+ embed.description = f"Failed to load cog: {cog}\n\n{e.__class__.__name__}: {e}"
else:
log.debug(f"{ctx.author} requested we load the '{cog}' cog. Cog loaded!")
embed.description = f"Cog loaded: {cog}"
@@ -93,7 +93,7 @@ class Cogs:
await ctx.send(embed=embed)
@cogs_group.command(name='unload', aliases=('ul',))
- @with_role(*MODERATION_ROLES, Roles.devops)
+ @with_role(*MODERATION_ROLES, Roles.core_developer)
async def unload_command(self, ctx: Context, cog: str) -> None:
"""
Unload an already-loaded cog, given the module containing it.
@@ -108,7 +108,7 @@ class Cogs:
embed.set_author(
name="Python Bot (Cogs)",
- url=URLs.gitlab_bot_repo,
+ url=URLs.github_bot_repo,
icon_url=URLs.bot_avatar
)
@@ -144,7 +144,7 @@ class Cogs:
await ctx.send(embed=embed)
@cogs_group.command(name='reload', aliases=('r',))
- @with_role(*MODERATION_ROLES, Roles.devops)
+ @with_role(*MODERATION_ROLES, Roles.core_developer)
async def reload_command(self, ctx: Context, cog: str) -> None:
"""
Reload an unloaded cog, given the module containing it.
@@ -162,7 +162,7 @@ class Cogs:
embed.set_author(
name="Python Bot (Cogs)",
- url=URLs.gitlab_bot_repo,
+ url=URLs.github_bot_repo,
icon_url=URLs.bot_avatar
)
@@ -194,7 +194,7 @@ class Cogs:
try:
self.bot.unload_extension(loaded_cog)
except Exception as e:
- failed_unloads[loaded_cog] = str(e)
+ failed_unloads[loaded_cog] = f"{e.__class__.__name__}: {e}"
else:
unloaded += 1
@@ -202,7 +202,7 @@ class Cogs:
try:
self.bot.load_extension(unloaded_cog)
except Exception as e:
- failed_loads[unloaded_cog] = str(e)
+ failed_loads[unloaded_cog] = f"{e.__class__.__name__}: {e}"
else:
loaded += 1
@@ -215,13 +215,13 @@ class Cogs:
lines.append("\n**Unload failures**")
for cog, error in failed_unloads:
- lines.append(f"`{cog}` {Emojis.status_dnd} `{error}`")
+ lines.append(f"{Emojis.status_dnd} **{cog}:** `{error}`")
if failed_loads:
lines.append("\n**Load failures**")
- for cog, error in failed_loads:
- lines.append(f"`{cog}` {Emojis.status_dnd} `{error}`")
+ for cog, error in failed_loads.items():
+ lines.append(f"{Emojis.status_dnd} **{cog}:** `{error}`")
log.debug(f"{ctx.author} requested we reload all cogs. Here are the results: \n"
f"{lines}")
@@ -248,7 +248,7 @@ class Cogs:
await ctx.send(embed=embed)
@cogs_group.command(name='list', aliases=('all',))
- @with_role(*MODERATION_ROLES, Roles.devops)
+ @with_role(*MODERATION_ROLES, Roles.core_developer)
async def list_command(self, ctx: Context) -> None:
"""
Get a list of all cogs, including their loaded status.
@@ -262,7 +262,7 @@ class Cogs:
embed.colour = Colour.blurple()
embed.set_author(
name="Python Bot (Cogs)",
- url=URLs.gitlab_bot_repo,
+ url=URLs.github_bot_repo,
icon_url=URLs.bot_avatar
)
diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py
index fc4dca0ee..9d530af64 100644
--- a/bot/cogs/defcon.py
+++ b/bot/cogs/defcon.py
@@ -2,10 +2,10 @@ import logging
from datetime import datetime, timedelta
from discord import Colour, Embed, Member
-from discord.ext.commands import Bot, Context, group
+from discord.ext.commands import Bot, Cog, Context, group
from bot.cogs.modlog import ModLog
-from bot.constants import Channels, Colours, Emojis, Event, Icons, Keys, Roles
+from bot.constants import Channels, Colours, Emojis, Event, Icons, Roles
from bot.decorators import with_role
log = logging.getLogger(__name__)
@@ -24,7 +24,7 @@ will be resolved soon. In the meantime, please feel free to peruse the resources
BASE_CHANNEL_TOPIC = "Python Discord Defense Mechanism"
-class Defcon:
+class Defcon(Cog):
"""Time-sensitive server defense mechanisms."""
days = None # type: timedelta
@@ -32,16 +32,18 @@ class Defcon:
def __init__(self, bot: Bot):
self.bot = bot
+ self.channel = None
self.days = timedelta(days=0)
- self.headers = {"X-API-KEY": Keys.site_api}
@property
def mod_log(self) -> ModLog:
"""Get currently loaded ModLog cog instance."""
return self.bot.get_cog("ModLog")
+ @Cog.listener()
async def on_ready(self) -> None:
"""On cog load, try to synchronize DEFCON settings to the API."""
+ self.channel = await self.bot.fetch_channel(Channels.defcon)
try:
response = await self.bot.api_client.get('bot/bot-settings/defcon')
data = response['data']
@@ -65,6 +67,7 @@ class Defcon:
await self.update_channel_topic()
+ @Cog.listener()
async def on_member_join(self, member: Member) -> None:
"""If DEFON is enabled, check newly joining users to see if they meet the account age threshold."""
if self.enabled and self.days.days > 0:
@@ -268,8 +271,7 @@ class Defcon:
new_topic = f"{BASE_CHANNEL_TOPIC}\n(Status: Disabled)"
self.mod_log.ignore(Event.guild_channel_update, Channels.defcon)
- defcon_channel = self.bot.guilds[0].get_channel(Channels.defcon)
- await defcon_channel.edit(topic=new_topic)
+ await self.channel.edit(topic=new_topic)
def setup(bot: Bot) -> None:
diff --git a/bot/cogs/deployment.py b/bot/cogs/deployment.py
deleted file mode 100644
index e8e8ba677..000000000
--- a/bot/cogs/deployment.py
+++ /dev/null
@@ -1,79 +0,0 @@
-import logging
-
-from discord import Colour, Embed
-from discord.ext.commands import Bot, Context, command, group
-
-from bot.constants import Keys, MODERATION_ROLES, Roles, URLs
-from bot.decorators import with_role
-
-log = logging.getLogger(__name__)
-
-
-class Deployment:
- """Bot information commands."""
-
- def __init__(self, bot: Bot):
- self.bot = bot
-
- @group(name='redeploy', invoke_without_command=True)
- @with_role(*MODERATION_ROLES)
- async def redeploy_group(self, ctx: Context) -> None:
- """Redeploy the bot or the site."""
- await ctx.invoke(self.bot.get_command("help"), "redeploy")
-
- @redeploy_group.command(name='bot')
- @with_role(Roles.admin, Roles.owner, Roles.devops)
- async def bot_command(self, ctx: Context) -> None:
- """Trigger bot deployment on the server - will only redeploy if there were changes to deploy."""
- response = await self.bot.http_session.get(URLs.deploy, headers={"token": Keys.deploy_bot})
- result = await response.text()
-
- if result == "True":
- log.debug(f"{ctx.author} triggered deployment for bot. Deployment was started.")
- await ctx.send(f"{ctx.author.mention} Bot deployment started.")
- else:
- log.error(f"{ctx.author} triggered deployment for bot. Deployment failed to start.")
- await ctx.send(f"{ctx.author.mention} Bot deployment failed - check the logs!")
-
- @redeploy_group.command(name='site')
- @with_role(Roles.admin, Roles.owner, Roles.devops)
- async def site_command(self, ctx: Context) -> None:
- """Trigger website deployment on the server - will only redeploy if there were changes to deploy."""
- response = await self.bot.http_session.get(URLs.deploy, headers={"token": Keys.deploy_bot})
- result = await response.text()
-
- if result == "True":
- log.debug(f"{ctx.author} triggered deployment for site. Deployment was started.")
- await ctx.send(f"{ctx.author.mention} Site deployment started.")
- else:
- log.error(f"{ctx.author} triggered deployment for site. Deployment failed to start.")
- await ctx.send(f"{ctx.author.mention} Site deployment failed - check the logs!")
-
- @command(name='uptimes')
- @with_role(Roles.admin, Roles.owner, Roles.devops)
- async def uptimes_command(self, ctx: Context) -> None:
- """Check the various deployment uptimes for each service."""
- log.debug(f"{ctx.author} requested service uptimes.")
- response = await self.bot.http_session.get(URLs.status)
- data = await response.json()
-
- embed = Embed(
- title="Service status",
- color=Colour.blurple()
- )
-
- for obj in data:
- key, value = list(obj.items())[0]
-
- embed.add_field(
- name=key, value=value, inline=True
- )
-
- log.debug("Uptimes retrieved and parsed, returning data.")
- await ctx.send(embed=embed)
-
-
-def setup(bot: Bot) -> None:
- """Deployment cog load."""
- bot.add_cog(Deployment(bot))
- log.info("Cog loaded: Deployment")
diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py
index ef14d1797..54a3172b8 100644
--- a/bot/cogs/doc.py
+++ b/bot/cogs/doc.py
@@ -118,7 +118,7 @@ class InventoryURL(commands.Converter):
return url
-class Doc:
+class Doc(commands.Cog):
"""A set of commands for querying & displaying documentation."""
def __init__(self, bot: commands.Bot):
@@ -126,6 +126,7 @@ class Doc:
self.bot = bot
self.inventories = {}
+ @commands.Cog.listener()
async def on_ready(self) -> None:
"""Refresh documentation inventory."""
await self.refresh_inventory()
diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py
index f8a27fda8..e74030c16 100644
--- a/bot/cogs/error_handler.py
+++ b/bot/cogs/error_handler.py
@@ -4,26 +4,32 @@ import logging
from discord.ext.commands import (
BadArgument,
BotMissingPermissions,
+ CheckFailure,
CommandError,
CommandInvokeError,
CommandNotFound,
+ CommandOnCooldown,
+ DisabledCommand,
+ MissingPermissions,
NoPrivateMessage,
UserInputError,
)
-from discord.ext.commands import Bot, Context
+from discord.ext.commands import Bot, Cog, Context
from bot.api import ResponseCodeError
-
+from bot.constants import Channels
+from bot.decorators import InChannelCheckFailure
log = logging.getLogger(__name__)
-class ErrorHandler:
+class ErrorHandler(Cog):
"""Handles errors emitted from commands."""
def __init__(self, bot: Bot):
self.bot = bot
+ @Cog.listener()
async def on_command_error(self, ctx: Context, e: CommandError) -> None:
"""Provide command error handling."""
command = ctx.command
@@ -32,6 +38,7 @@ class ErrorHandler:
if command is not None:
parent = command.parent
+ # Retrieve the help command for the invoked command.
if parent and command:
help_command = (self.bot.get_command("help"), parent.name, command.name)
elif command:
@@ -39,53 +46,80 @@ class ErrorHandler:
else:
help_command = (self.bot.get_command("help"),)
- if hasattr(command, "on_error"):
- log.debug(f"Command {command} has a local error handler, ignoring.")
+ if hasattr(e, "handled"):
+ log.trace(f"Command {command} had its error already handled locally; ignoring.")
return
+ # Try to look for a tag with the command's name if the command isn't found.
if isinstance(e, CommandNotFound) and not hasattr(ctx, "invoked_from_error_handler"):
- tags_get_command = self.bot.get_command("tags get")
- ctx.invoked_from_error_handler = True
+ if not ctx.channel.id == Channels.verification:
+ tags_get_command = self.bot.get_command("tags get")
+ ctx.invoked_from_error_handler = True
- # Return to not raise the exception
- with contextlib.suppress(ResponseCodeError):
- return await ctx.invoke(tags_get_command, tag_name=ctx.invoked_with)
+ # Return to not raise the exception
+ with contextlib.suppress(ResponseCodeError):
+ return await ctx.invoke(tags_get_command, tag_name=ctx.invoked_with)
elif isinstance(e, BadArgument):
await ctx.send(f"Bad argument: {e}\n")
await ctx.invoke(*help_command)
elif isinstance(e, UserInputError):
await ctx.send("Something about your input seems off. Check the arguments:")
await ctx.invoke(*help_command)
+ log.debug(
+ f"Command {command} invoked by {ctx.message.author} with error "
+ f"{e.__class__.__name__}: {e}"
+ )
elif isinstance(e, NoPrivateMessage):
await ctx.send("Sorry, this command can't be used in a private message!")
elif isinstance(e, BotMissingPermissions):
- await ctx.send(
- f"Sorry, it looks like I don't have the permissions I need to do that.\n\n"
- f"Here's what I'm missing: **{e.missing_perms}**"
+ await ctx.send(f"Sorry, it looks like I don't have the permissions I need to do that.")
+ log.warning(
+ f"The bot is missing permissions to execute command {command}: {e.missing_perms}"
+ )
+ elif isinstance(e, MissingPermissions):
+ log.debug(
+ f"{ctx.message.author} is missing permissions to invoke command {command}: "
+ f"{e.missing_perms}"
+ )
+ elif isinstance(e, InChannelCheckFailure):
+ await ctx.send(e)
+ elif isinstance(e, (CheckFailure, CommandOnCooldown, DisabledCommand)):
+ log.debug(
+ f"Command {command} invoked by {ctx.message.author} with error "
+ f"{e.__class__.__name__}: {e}"
)
elif isinstance(e, CommandInvokeError):
if isinstance(e.original, ResponseCodeError):
- if e.original.response.status == 404:
+ status = e.original.response.status
+
+ if status == 404:
await ctx.send("There does not seem to be anything matching your query.")
- elif e.original.response.status == 400:
+ elif status == 400:
content = await e.original.response.json()
- log.debug("API gave bad request on command. Response: %r.", content)
+ log.debug(f"API responded with 400 for command {command}: %r.", content)
await ctx.send("According to the API, your request is malformed.")
- elif 500 <= e.original.response.status < 600:
+ elif 500 <= status < 600:
await ctx.send("Sorry, there seems to be an internal issue with the API.")
+ log.warning(f"API responded with {status} for command {command}")
else:
- await ctx.send(
- "Got an unexpected status code from the "
- f"API (`{e.original.response.code}`)."
- )
-
+ await ctx.send(f"Got an unexpected status code from the API (`{status}`).")
+ log.warning(f"Unexpected API response for command {command}: {status}")
else:
- await ctx.send(
- f"Sorry, an unexpected error occurred. Please let us know!\n\n```{e}```"
- )
- raise e.original
+ await self.handle_unexpected_error(ctx, e.original)
else:
- raise e
+ await self.handle_unexpected_error(ctx, e)
+
+ @staticmethod
+ async def handle_unexpected_error(ctx: Context, e: CommandError) -> None:
+ """Generic handler for errors without an explicit handler."""
+ await ctx.send(
+ f"Sorry, an unexpected error occurred. Please let us know!\n\n"
+ f"```{e.__class__.__name__}: {e}```"
+ )
+ log.error(
+ f"Error executing command invoked by {ctx.message.author}: {ctx.message.content}"
+ )
+ raise e
def setup(bot: Bot) -> None:
diff --git a/bot/cogs/eval.py b/bot/cogs/eval.py
index 5fbd9dca5..578a494e7 100644
--- a/bot/cogs/eval.py
+++ b/bot/cogs/eval.py
@@ -9,7 +9,7 @@ from io import StringIO
from typing import Any, Tuple, Union
import discord
-from discord.ext.commands import Bot, group
+from discord.ext.commands import Bot, Cog, group
from bot.constants import Roles
from bot.decorators import with_role
@@ -18,7 +18,7 @@ from bot.interpreter import Interpreter
log = logging.getLogger(__name__)
-class CodeEval:
+class CodeEval(Cog):
"""Owner and admin feature that evaluates code and returns the result to the channel."""
def __init__(self, bot: Bot):
diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py
index b924ac265..2eb53e61b 100644
--- a/bot/cogs/filtering.py
+++ b/bot/cogs/filtering.py
@@ -5,7 +5,7 @@ from typing import Optional, Union
import discord.errors
from dateutil.relativedelta import relativedelta
from discord import Colour, DMChannel, Member, Message, TextChannel
-from discord.ext.commands import Bot
+from discord.ext.commands import Bot, Cog
from bot.cogs.modlog import ModLog
from bot.constants import (
@@ -29,7 +29,7 @@ URL_RE = r"(https?://[^\s]+)"
ZALGO_RE = r"[\u0300-\u036F\u0489]"
-class Filtering:
+class Filtering(Cog):
"""Filtering out invites, blacklisting domains, and warning us of certain regular expressions."""
def __init__(self, bot: Bot):
@@ -56,7 +56,7 @@ class Filtering:
"user_notification": Filter.notify_user_invites,
"notification_msg": (
f"Per Rule 10, your invite link has been removed. {_staff_mistake_str}\n\n"
- r"Our server rules can be found here: <https://pythondiscord.com/about/rules>"
+ r"Our server rules can be found here: <https://pythondiscord.com/pages/rules>"
)
},
"filter_domains": {
@@ -94,10 +94,12 @@ class Filtering:
"""Get currently loaded ModLog cog instance."""
return self.bot.get_cog("ModLog")
+ @Cog.listener()
async def on_message(self, msg: Message) -> None:
"""Invoke message filter for new messages."""
await self._filter_message(msg)
+ @Cog.listener()
async def on_message_edit(self, before: Message, after: Message) -> None:
"""
Invoke message filter for message edits.
@@ -107,7 +109,7 @@ class Filtering:
if not before.edited_at:
delta = relativedelta(after.edited_at, before.created_at).microseconds
else:
- delta = None
+ delta = relativedelta(after.edited_at, before.edited_at).microseconds
await self._filter_message(after, delta)
async def _filter_message(self, msg: Message, delta: Optional[int] = None) -> None:
@@ -140,7 +142,7 @@ class Filtering:
# If the edit delta is less than 0.001 seconds, then we're probably dealing
# with a double filter trigger.
if delta is not None and delta < 100:
- return
+ continue
# Does the filter only need the message content or the full message?
if _filter["content_only"]:
diff --git a/bot/cogs/free.py b/bot/cogs/free.py
index ccc722e66..167fab319 100644
--- a/bot/cogs/free.py
+++ b/bot/cogs/free.py
@@ -2,7 +2,7 @@ import logging
from datetime import datetime
from discord import Colour, Embed, Member, utils
-from discord.ext.commands import Bot, Context, command
+from discord.ext.commands import Bot, Cog, Context, command
from bot.constants import Categories, Channels, Free, STAFF_ROLES
from bot.decorators import redirect_output
@@ -15,7 +15,7 @@ RATE = Free.cooldown_rate
PER = Free.cooldown_per
-class Free:
+class Free(Cog):
"""Tries to figure out which help channels are free."""
PYTHON_HELP_ID = Categories.python_help
diff --git a/bot/cogs/help.py b/bot/cogs/help.py
index 68c59d326..63421c8a7 100644
--- a/bot/cogs/help.py
+++ b/bot/cogs/help.py
@@ -7,7 +7,7 @@ from typing import Union
from discord import Colour, Embed, HTTPException, Message, Reaction, User
from discord.ext import commands
-from discord.ext.commands import Bot, CheckFailure, Command, Context
+from discord.ext.commands import Bot, CheckFailure, Cog as DiscordCog, Command, Context
from fuzzywuzzy import fuzz, process
from bot import constants
@@ -110,7 +110,7 @@ class HelpSession:
self.query = ctx.bot
self.description = self.query.description
self.author = ctx.author
- self.destination = ctx.author if ctx.bot.pm_help else ctx.channel
+ self.destination = ctx.channel
# set the config for the session
self._cleanup = cleanup
@@ -528,7 +528,7 @@ class HelpSession:
await self.message.delete()
-class Help:
+class Help(DiscordCog):
"""Custom Embed Pagination Help feature."""
@commands.command('help')
diff --git a/bot/cogs/information.py b/bot/cogs/information.py
index 3495f6181..60aec6219 100644
--- a/bot/cogs/information.py
+++ b/bot/cogs/information.py
@@ -1,28 +1,22 @@
import logging
-import random
import textwrap
from discord import CategoryChannel, Colour, Embed, Member, TextChannel, VoiceChannel
-from discord.ext.commands import (
- BadArgument, Bot, CommandError, Context, MissingPermissions, command
-)
-
-from bot.constants import (
- Channels, Emojis, Keys, MODERATION_ROLES, NEGATIVE_REPLIES, STAFF_ROLES
-)
-from bot.decorators import with_role
+from discord.ext.commands import Bot, Cog, Context, command
+
+from bot.constants import Channels, Emojis, MODERATION_ROLES, STAFF_ROLES
+from bot.decorators import InChannelCheckFailure, with_role
from bot.utils.checks import with_role_check
from bot.utils.time import time_since
log = logging.getLogger(__name__)
-class Information:
+class Information(Cog):
"""A cog with commands for generating embeds with server info, such as server stats and user info."""
def __init__(self, bot: Bot):
self.bot = bot
- self.headers = {"X-API-Key": Keys.site_api}
@with_role(*MODERATION_ROLES)
@command(name="roles")
@@ -116,30 +110,25 @@ class Information:
@command(name="user", aliases=["user_info", "member", "member_info"])
async def user_info(self, ctx: Context, user: Member = None, hidden: bool = False) -> None:
"""Returns info about a user."""
- # Do a role check if this is being executed on
- # someone other than the caller
- if user and user != ctx.author:
- if not with_role_check(ctx, *MODERATION_ROLES):
- raise BadArgument("You do not have permission to use this command on users other than yourself.")
-
- # Non-moderators may only do this in #bot-commands and can't see
- # hidden infractions.
+ if user is None:
+ user = ctx.author
+
+ # Do a role check if this is being executed on someone other than the caller
+ if user != ctx.author and not with_role_check(ctx, *MODERATION_ROLES):
+ await ctx.send("You may not use this command on users other than yourself.")
+ return
+
+ # Non-moderators may only do this in #bot-commands and can't see hidden infractions.
if not with_role_check(ctx, *STAFF_ROLES):
if not ctx.channel.id == Channels.bot:
- raise MissingPermissions("You can't do that here!")
+ raise InChannelCheckFailure(Channels.bot)
# Hide hidden infractions for users without a moderation role
hidden = False
- # Validates hidden input
- hidden = str(hidden)
-
- if user is None:
- user = ctx.author
-
# User information
created = time_since(user.created_at, max_units=3)
- name = f"{user.name}#{user.discriminator}"
+ name = str(user)
if user.nick:
name = f"{user.nick} ({name})"
@@ -147,15 +136,13 @@ class Information:
joined = time_since(user.joined_at, precision="days")
# You're welcome, Volcyyyyyyyyyyyyyyyy
- roles = ", ".join(
- role.mention for role in user.roles if role.name != "@everyone"
- )
+ roles = ", ".join(role.mention for role in user.roles if role.name != "@everyone")
# Infractions
infractions = await self.bot.api_client.get(
'bot/infractions',
params={
- 'hidden': hidden,
+ 'hidden': str(hidden),
'user__id': str(user.id)
}
)
@@ -194,24 +181,6 @@ class Information:
await ctx.send(embed=embed)
- @user_info.error
- async def user_info_command_error(self, ctx: Context, error: CommandError) -> None:
- """Info commands error handler."""
- embed = Embed(colour=Colour.red())
-
- if isinstance(error, BadArgument):
- embed.title = random.choice(NEGATIVE_REPLIES)
- embed.description = str(error)
- await ctx.send(embed=embed)
-
- elif isinstance(error, MissingPermissions):
- embed.title = random.choice(NEGATIVE_REPLIES)
- embed.description = f"Sorry, but you may only use this command within <#{Channels.bot}>."
- await ctx.send(embed=embed)
-
- else:
- log.exception(f"Unhandled error: {error}")
-
def setup(bot: Bot) -> None:
"""Information cog load."""
diff --git a/bot/cogs/jams.py b/bot/cogs/jams.py
index f7a5896c0..be9d33e3e 100644
--- a/bot/cogs/jams.py
+++ b/bot/cogs/jams.py
@@ -2,6 +2,7 @@ import logging
from discord import Member, PermissionOverwrite, utils
from discord.ext import commands
+from more_itertools import unique_everseen
from bot.constants import Roles
from bot.decorators import with_role
@@ -9,7 +10,7 @@ from bot.decorators import with_role
log = logging.getLogger(__name__)
-class CodeJams:
+class CodeJams(commands.Cog):
"""Manages the code-jam related parts of our server."""
def __init__(self, bot: commands.Bot):
@@ -17,23 +18,25 @@ class CodeJams:
@commands.command()
@with_role(Roles.admin)
- async def createteam(
- self, ctx: commands.Context,
- team_name: str, members: commands.Greedy[Member]
- ) -> None:
+ async def createteam(self, ctx: commands.Context, team_name: str, members: commands.Greedy[Member]) -> None:
"""
Create team channels (voice and text) in the Code Jams category, assign roles, and add overwrites for the team.
The first user passed will always be the team leader.
"""
+ # Ignore duplicate members
+ members = list(unique_everseen(members))
+
# We had a little issue during Code Jam 4 here, the greedy converter did it's job
# and ignored anything which wasn't a valid argument which left us with teams of
# two members or at some times even 1 member. This fixes that by checking that there
# are always 3 members in the members list.
if len(members) < 3:
- await ctx.send(":no_entry_sign: One of your arguments was invalid - there must be a "
- f"minimum of 3 valid members in your team. Found: {len(members)} "
- "members")
+ await ctx.send(
+ ":no_entry_sign: One of your arguments was invalid\n"
+ f"There must be a minimum of 3 valid members in your team. Found: {len(members)}"
+ " members"
+ )
return
code_jam_category = utils.get(ctx.guild.categories, name="Code Jam")
@@ -61,7 +64,7 @@ class CodeJams:
connect=True
),
ctx.guild.default_role: PermissionOverwrite(read_messages=False, connect=False),
- ctx.guild.get_role(Roles.developer): PermissionOverwrite(
+ ctx.guild.get_role(Roles.verified): PermissionOverwrite(
read_messages=False,
connect=False
)
@@ -98,7 +101,11 @@ class CodeJams:
for member in members:
await member.add_roles(jammer_role)
- await ctx.send(f":ok_hand: Team created: {team_channel.mention}")
+ await ctx.send(
+ f":ok_hand: Team created: {team_channel.mention}\n"
+ f"**Team Leader:** {members[0].mention}\n"
+ f"**Team Members:** {' '.join(member.mention for member in members[1:])}"
+ )
def setup(bot: commands.Bot) -> None:
diff --git a/bot/cogs/logging.py b/bot/cogs/logging.py
index 22d770c04..8e47bcc36 100644
--- a/bot/cogs/logging.py
+++ b/bot/cogs/logging.py
@@ -1,7 +1,7 @@
import logging
from discord import Embed
-from discord.ext.commands import Bot
+from discord.ext.commands import Bot, Cog
from bot.constants import Channels, DEBUG_MODE
@@ -9,12 +9,13 @@ from bot.constants import Channels, DEBUG_MODE
log = logging.getLogger(__name__)
-class Logging:
+class Logging(Cog):
"""Debug logging module."""
def __init__(self, bot: Bot):
self.bot = bot
+ @Cog.listener()
async def on_ready(self) -> None:
"""Announce our presence to the configured devlog channel."""
log.info("Bot connected!")
@@ -23,7 +24,10 @@ class Logging:
embed.set_author(
name="Python Bot",
url="https://github.com/python-discord/bot",
- icon_url="https://github.com/python-discord/branding/blob/master/logos/logo_circle/logo_circle_256.png"
+ icon_url=(
+ "https://raw.githubusercontent.com/"
+ "python-discord/branding/master/logos/logo_circle/logo_circle_large.png"
+ )
)
if not DEBUG_MODE:
diff --git a/bot/cogs/moderation.py b/bot/cogs/moderation.py
index 28956e636..d407a90fe 100644
--- a/bot/cogs/moderation.py
+++ b/bot/cogs/moderation.py
@@ -2,13 +2,13 @@ import asyncio
import logging
import textwrap
from datetime import datetime
-from typing import Union
+from typing import Dict, Union
from discord import (
- Colour, Embed, Forbidden, Guild, HTTPException, Member, Object, User
+ Colour, Embed, Forbidden, Guild, HTTPException, Member, NotFound, Object, User
)
from discord.ext.commands import (
- BadArgument, BadUnionArgument, Bot, Context, command, group
+ BadArgument, BadUnionArgument, Bot, Cog, Context, command, group
)
from bot import constants
@@ -17,7 +17,7 @@ from bot.constants import Colours, Event, Icons, MODERATION_ROLES
from bot.converters import ExpirationDate, InfractionSearchQuery
from bot.decorators import with_role
from bot.pagination import LinePaginator
-from bot.utils.moderation import post_infraction
+from bot.utils.moderation import already_has_active_infraction, post_infraction
from bot.utils.scheduling import Scheduler, create_task
from bot.utils.time import wait_until
@@ -28,7 +28,7 @@ INFRACTION_ICONS = {
"Kick": Icons.sign_out,
"Ban": Icons.user_ban
}
-RULES_URL = "https://pythondiscord.com/about/rules"
+RULES_URL = "https://pythondiscord.com/pages/rules"
APPEALABLE_INFRACTIONS = ("Ban", "Mute")
@@ -47,7 +47,7 @@ def proxy_user(user_id: str) -> Object:
UserTypes = Union[Member, User, proxy_user]
-class Moderation(Scheduler):
+class Moderation(Scheduler, Cog):
"""Server moderation tools."""
def __init__(self, bot: Bot):
@@ -60,8 +60,10 @@ class Moderation(Scheduler):
"""Get currently loaded ModLog cog instance."""
return self.bot.get_cog("ModLog")
+ @Cog.listener()
async def on_ready(self) -> None:
"""Schedule expiration for previous infractions."""
+ # Schedule expiration for previous infractions
infractions = await self.bot.api_client.get(
'bot/infractions', params={'active': 'true'}
)
@@ -74,21 +76,12 @@ class Moderation(Scheduler):
@with_role(*MODERATION_ROLES)
@command()
async def warn(self, ctx: Context, user: UserTypes, *, reason: str = None) -> None:
- """
- Create a warning infraction in the database for a user.
-
- **`user`:** Accepts user mention, ID, etc.
- **`reason`:** The reason for the warning.
- """
- response_object = await post_infraction(ctx, user, type="warning", reason=reason)
- if response_object is None:
+ """Create a warning infraction in the database for a user."""
+ infraction = await post_infraction(ctx, user, type="warning", reason=reason)
+ if infraction is None:
return
- notified = await self.notify_infraction(
- user=user,
- infr_type="Warning",
- reason=reason
- )
+ notified = await self.notify_infraction(user=user, infr_type="Warning", reason=reason)
dm_result = ":incoming_envelope: " if notified else ""
action = f"{dm_result}:ok_hand: warned {user.mention}"
@@ -117,7 +110,7 @@ class Moderation(Scheduler):
Reason: {reason}
"""),
content=log_content,
- footer=f"ID {response_object['infraction']['id']}"
+ footer=f"ID {infraction['id']}"
)
@with_role(*MODERATION_ROLES)
@@ -129,15 +122,11 @@ class Moderation(Scheduler):
# Warning is sent to ctx by the helper method
return
- response_object = await post_infraction(ctx, user, type="kick", reason=reason)
- if response_object is None:
+ infraction = await post_infraction(ctx, user, type="kick", reason=reason)
+ if infraction is None:
return
- notified = await self.notify_infraction(
- user=user,
- infr_type="Kick",
- reason=reason
- )
+ notified = await self.notify_infraction(user=user, infr_type="Kick", reason=reason)
self.mod_log.ignore(Event.member_remove, user.id)
@@ -171,7 +160,7 @@ class Moderation(Scheduler):
Reason: {reason}
"""),
content=log_content,
- footer=f"ID {response_object['infraction']['id']}"
+ footer=f"ID {infraction['id']}"
)
@with_role(*MODERATION_ROLES)
@@ -183,22 +172,11 @@ class Moderation(Scheduler):
# Warning is sent to ctx by the helper method
return
- active_bans = await self.bot.api_client.get(
- 'bot/infractions',
- params={
- 'active': 'true',
- 'type': 'ban',
- 'user__id': str(user.id)
- }
- )
- if active_bans:
- return await ctx.send(
- ":x: According to my records, this user is already banned. "
- f"See infraction **#{active_bans[0]['id']}**."
- )
+ if await already_has_active_infraction(ctx=ctx, user=user, type="ban"):
+ return
- response_object = await post_infraction(ctx, user, type="ban", reason=reason)
- if response_object is None:
+ infraction = await post_infraction(ctx, user, type="ban", reason=reason)
+ if infraction is None:
return
notified = await self.notify_infraction(
@@ -242,29 +220,18 @@ class Moderation(Scheduler):
Reason: {reason}
"""),
content=log_content,
- footer=f"ID {response_object['infraction']['id']}"
+ footer=f"ID {infraction['id']}"
)
@with_role(*MODERATION_ROLES)
@command()
async def mute(self, ctx: Context, user: Member, *, reason: str = None) -> None:
"""Create a permanent mute infraction for a user with the provided reason."""
- active_mutes = await self.bot.api_client.get(
- 'bot/infractions',
- params={
- 'active': 'true',
- 'type': 'mute',
- 'user__id': str(user.id)
- }
- )
- if active_mutes:
- return await ctx.send(
- ":x: According to my records, this user is already muted. "
- f"See infraction **#{active_mutes[0]['id']}**."
- )
+ if await already_has_active_infraction(ctx=ctx, user=user, type="mute"):
+ return
- response_object = await post_infraction(ctx, user, type="mute", reason=reason)
- if response_object is None:
+ infraction = await post_infraction(ctx, user, type="mute", reason=reason)
+ if infraction is None:
return
self.mod_log.ignore(Event.member_update, user.id)
@@ -304,7 +271,7 @@ class Moderation(Scheduler):
Reason: {reason}
"""),
content=log_content,
- footer=f"ID {response_object['infraction']['id']}"
+ footer=f"ID {infraction['id']}"
)
# endregion
@@ -312,34 +279,20 @@ class Moderation(Scheduler):
@with_role(*MODERATION_ROLES)
@command()
- async def tempmute(
- self, ctx: Context, user: Member, expiration: ExpirationDate,
- *, reason: str = None
- ) -> None:
+ async def tempmute(self, ctx: Context, user: Member, duration: ExpirationDate, *, reason: str = None) -> None:
"""
Create a temporary mute infraction for a user with the provided expiration and reason.
Duration strings are parsed per: http://strftime.org/
"""
- active_mutes = await self.bot.api_client.get(
- 'bot/infractions',
- params={
- 'active': 'true',
- 'type': 'mute',
- 'user__id': str(user.id)
- }
- )
- if active_mutes:
- return await ctx.send(
- ":x: According to my records, this user is already muted. "
- f"See infraction **#{active_mutes[0]['id']}**."
- )
+ expiration = duration
- infraction = await post_infraction(
- ctx, user,
- type="mute", reason=reason,
- expires_at=expiration
- )
+ if await already_has_active_infraction(ctx=ctx, user=user, type="mute"):
+ return
+
+ infraction = await post_infraction(ctx, user, type="mute", reason=reason, expires_at=expiration)
+ if infraction is None:
+ return
self.mod_log.ignore(Event.member_update, user.id)
await user.add_roles(self._muted_role, reason=reason)
@@ -392,44 +345,30 @@ class Moderation(Scheduler):
@with_role(*MODERATION_ROLES)
@command()
- async def tempban(
- self, ctx: Context, user: UserTypes, expiry: ExpirationDate, *, reason: str = None
- ) -> None:
+ async def tempban(self, ctx: Context, user: UserTypes, duration: ExpirationDate, *, reason: str = None) -> None:
"""
Create a temporary ban infraction for a user with the provided expiration and reason.
Duration strings are parsed per: http://strftime.org/
"""
+ expiration = duration
+
if not await self.respect_role_hierarchy(ctx, user, 'tempban'):
# Ensure ctx author has a higher top role than the target user
# Warning is sent to ctx by the helper method
return
- active_bans = await self.bot.api_client.get(
- 'bot/infractions',
- params={
- 'active': 'true',
- 'type': 'ban',
- 'user__id': str(user.id)
- }
- )
- if active_bans:
- return await ctx.send(
- ":x: According to my records, this user is already banned. "
- f"See infraction **#{active_bans[0]['id']}**."
- )
+ if await already_has_active_infraction(ctx=ctx, user=user, type="ban"):
+ return
- infraction = await post_infraction(
- ctx, user, type="ban",
- reason=reason, expires_at=expiry
- )
+ infraction = await post_infraction(ctx, user, type="ban", reason=reason, expires_at=expiration)
if infraction is None:
return
notified = await self.notify_infraction(
user=user,
infr_type="Ban",
- expires_at=expiry,
+ expires_at=expiration,
reason=reason
)
@@ -491,11 +430,8 @@ class Moderation(Scheduler):
This does not send the user a notification
"""
- response_object = await post_infraction(
- ctx, user, type="warning", reason=reason, hidden=True
- )
-
- if response_object is None:
+ infraction = await post_infraction(ctx, user, type="warning", reason=reason, hidden=True)
+ if infraction is None:
return
if reason is None:
@@ -513,7 +449,7 @@ class Moderation(Scheduler):
Actor: {ctx.message.author}
Reason: {reason}
"""),
- footer=f"ID {response_object['infraction']['id']}"
+ footer=f"ID {infraction['id']}"
)
@with_role(*MODERATION_ROLES)
@@ -529,8 +465,8 @@ class Moderation(Scheduler):
# Warning is sent to ctx by the helper method
return
- response_object = await post_infraction(ctx, user, type="kick", reason=reason, hidden=True)
- if response_object is None:
+ infraction = await post_infraction(ctx, user, type="kick", reason=reason, hidden=True)
+ if infraction is None:
return
self.mod_log.ignore(Event.member_remove, user.id)
@@ -564,7 +500,7 @@ class Moderation(Scheduler):
Reason: {reason}
"""),
content=log_content,
- footer=f"ID {response_object['infraction']['id']}"
+ footer=f"ID {infraction['id']}"
)
@with_role(*MODERATION_ROLES)
@@ -580,8 +516,11 @@ class Moderation(Scheduler):
# Warning is sent to ctx by the helper method
return
- response_object = await post_infraction(ctx, user, type="ban", reason=reason, hidden=True)
- if response_object is None:
+ if await already_has_active_infraction(ctx=ctx, user=user, type="ban"):
+ return
+
+ infraction = await post_infraction(ctx, user, type="ban", reason=reason, hidden=True)
+ if infraction is None:
return
self.mod_log.ignore(Event.member_ban, user.id)
@@ -616,7 +555,7 @@ class Moderation(Scheduler):
Reason: {reason}
"""),
content=log_content,
- footer=f"ID {response_object['infraction']['id']}"
+ footer=f"ID {infraction['id']}"
)
@with_role(*MODERATION_ROLES)
@@ -627,8 +566,11 @@ class Moderation(Scheduler):
This does not send the user a notification.
"""
- response_object = await post_infraction(ctx, user, type="mute", reason=reason, hidden=True)
- if response_object is None:
+ if await already_has_active_infraction(ctx=ctx, user=user, type="mute"):
+ return
+
+ infraction = await post_infraction(ctx, user, type="mute", reason=reason, hidden=True)
+ if infraction is None:
return
self.mod_log.ignore(Event.member_update, user.id)
@@ -649,7 +591,7 @@ class Moderation(Scheduler):
Actor: {ctx.message.author}
Reason: {reason}
"""),
- footer=f"ID {response_object['infraction']['id']}"
+ footer=f"ID {infraction['id']}"
)
# endregion
@@ -658,7 +600,7 @@ class Moderation(Scheduler):
@with_role(*MODERATION_ROLES)
@command(hidden=True, aliases=["shadowtempmute, stempmute"])
async def shadow_tempmute(
- self, ctx: Context, user: Member, duration: str, *, reason: str = None
+ self, ctx: Context, user: Member, duration: ExpirationDate, *, reason: str = None
) -> None:
"""
Create a temporary mute infraction for a user with the provided reason.
@@ -667,19 +609,25 @@ class Moderation(Scheduler):
This does not send the user a notification.
"""
- response_object = await post_infraction(
- ctx, user, type="mute", reason=reason, duration=duration, hidden=True
- )
- if response_object is None:
+ expiration = duration
+
+ if await already_has_active_infraction(ctx=ctx, user=user, type="mute"):
+ return
+
+ infraction = await post_infraction(ctx, user, type="mute", reason=reason, expires_at=expiration, hidden=True)
+ if infraction is None:
return
self.mod_log.ignore(Event.member_update, user.id)
await user.add_roles(self._muted_role, reason=reason)
- infraction_object = response_object["infraction"]
- infraction_expiration = infraction_object["expires_at"]
+ infraction_expiration = (
+ datetime
+ .fromisoformat(infraction["expires_at"][:-1])
+ .strftime('%c')
+ )
- self.schedule_expiration(ctx.bot.loop, infraction_object)
+ self.schedule_task(ctx.bot.loop, infraction["id"], infraction)
if reason is None:
await ctx.send(f":ok_hand: muted {user.mention} until {infraction_expiration}.")
@@ -697,16 +645,15 @@ class Moderation(Scheduler):
Member: {user.mention} (`{user.id}`)
Actor: {ctx.message.author}
Reason: {reason}
- Duration: {duration}
Expires: {infraction_expiration}
"""),
- footer=f"ID {response_object['infraction']['id']}"
+ footer=f"ID {infraction['id']}"
)
@with_role(*MODERATION_ROLES)
@command(hidden=True, aliases=["shadowtempban, stempban"])
async def shadow_tempban(
- self, ctx: Context, user: UserTypes, duration: str, *, reason: str = None
+ self, ctx: Context, user: UserTypes, duration: ExpirationDate, *, reason: str = None
) -> None:
"""
Create a temporary ban infraction for a user with the provided reason.
@@ -715,15 +662,18 @@ class Moderation(Scheduler):
This does not send the user a notification.
"""
+ expiration = duration
+
if not await self.respect_role_hierarchy(ctx, user, 'shadowtempban'):
# Ensure ctx author has a higher top role than the target user
# Warning is sent to ctx by the helper method
return
- response_object = await post_infraction(
- ctx, user, type="ban", reason=reason, duration=duration, hidden=True
- )
- if response_object is None:
+ if await already_has_active_infraction(ctx=ctx, user=user, type="ban"):
+ return
+
+ infraction = await post_infraction(ctx, user, type="ban", reason=reason, expires_at=expiration, hidden=True)
+ if infraction is None:
return
self.mod_log.ignore(Event.member_ban, user.id)
@@ -735,10 +685,13 @@ class Moderation(Scheduler):
except Forbidden:
action_result = False
- infraction_object = response_object["infraction"]
- infraction_expiration = infraction_object["expires_at"]
+ infraction_expiration = (
+ datetime
+ .fromisoformat(infraction["expires_at"][:-1])
+ .strftime('%c')
+ )
- self.schedule_expiration(ctx.bot.loop, infraction_object)
+ self.schedule_task(ctx.bot.loop, infraction["id"], infraction)
if reason is None:
await ctx.send(f":ok_hand: banned {user.mention} until {infraction_expiration}.")
@@ -764,11 +717,10 @@ class Moderation(Scheduler):
Member: {user.mention} (`{user.id}`)
Actor: {ctx.message.author}
Reason: {reason}
- Duration: {duration}
Expires: {infraction_expiration}
"""),
content=log_content,
- footer=f"ID {response_object['infraction']['id']}"
+ footer=f"ID {infraction['id']}"
)
# endregion
@@ -776,7 +728,7 @@ class Moderation(Scheduler):
@with_role(*MODERATION_ROLES)
@command()
- async def unmute(self, ctx: Context, user: Member) -> None:
+ async def unmute(self, ctx: Context, user: UserTypes) -> None:
"""Deactivates the active mute infraction for a user."""
try:
# check the current active infraction
@@ -793,14 +745,15 @@ class Moderation(Scheduler):
if not response:
# no active infraction
- return await ctx.send(
+ await ctx.send(
f":x: There is no active mute infraction for user {user.mention}."
)
+ return
- infraction = response[0]
- await self._deactivate_infraction(infraction)
- if infraction["expires_at"] is not None:
- self.cancel_expiration(infraction["id"])
+ for infraction in response:
+ await self._deactivate_infraction(infraction)
+ if infraction["expires_at"] is not None:
+ self.cancel_expiration(infraction["id"])
notified = await self.notify_pardon(
user=user,
@@ -820,19 +773,31 @@ class Moderation(Scheduler):
await ctx.send(f"{dm_emoji}:ok_hand: Un-muted {user.mention}.")
+ embed_text = textwrap.dedent(
+ f"""
+ Member: {user.mention} (`{user.id}`)
+ Actor: {ctx.message.author}
+ DM: {dm_status}
+ """
+ )
+
+ if len(response) > 1:
+ footer = f"Infraction IDs: {', '.join(str(infr['id']) for infr in response)}"
+ title = "Member unmuted"
+ embed_text += "Note: User had multiple **active** mute infractions in the database."
+ else:
+ infraction = response[0]
+ footer = f"Infraction ID: {infraction['id']}"
+ title = "Member unmuted"
+
# Send a log message to the mod log
await self.mod_log.send_log_message(
icon_url=Icons.user_unmute,
colour=Colour(Colours.soft_green),
- title="Member unmuted",
+ title=title,
thumbnail=user.avatar_url_as(static_format="png"),
- text=textwrap.dedent(f"""
- Member: {user.mention} (`{user.id}`)
- Actor: {ctx.message.author}
- Intended expiry: {infraction['expires_at']}
- DM: {dm_status}
- """),
- footer=infraction["id"],
+ text=embed_text,
+ footer=footer,
content=log_content
)
except Exception:
@@ -861,14 +826,29 @@ class Moderation(Scheduler):
if not response:
# no active infraction
- return await ctx.send(
+ await ctx.send(
f":x: There is no active ban infraction for user {user.mention}."
)
+ return
- infraction = response[0]
- await self._deactivate_infraction(infraction)
- if infraction["expires_at"] is not None:
- self.cancel_expiration(infraction["id"])
+ for infraction in response:
+ await self._deactivate_infraction(infraction)
+ if infraction["expires_at"] is not None:
+ self.cancel_expiration(infraction["id"])
+
+ embed_text = textwrap.dedent(
+ f"""
+ Member: {user.mention} (`{user.id}`)
+ Actor: {ctx.message.author}
+ """
+ )
+
+ if len(response) > 1:
+ footer = f"Infraction IDs: {', '.join(str(infr['id']) for infr in response)}"
+ embed_text += "Note: User had multiple **active** ban infractions in the database."
+ else:
+ infraction = response[0]
+ footer = f"Infraction ID: {infraction['id']}"
await ctx.send(f":ok_hand: Un-banned {user.mention}.")
@@ -878,11 +858,8 @@ class Moderation(Scheduler):
colour=Colour(Colours.soft_green),
title="Member unbanned",
thumbnail=user.avatar_url_as(static_format="png"),
- text=textwrap.dedent(f"""
- Member: {user.mention} (`{user.id}`)
- Actor: {ctx.message.author}
- Intended expiry: {infraction['expires_at']}
- """)
+ text=embed_text,
+ footer=footer,
)
except Exception:
log.exception("There was an error removing an infraction.")
@@ -1008,7 +985,8 @@ class Moderation(Scheduler):
except Exception:
log.exception("There was an error updating an infraction.")
- return await ctx.send(":x: There was an error updating the infraction.")
+ await ctx.send(":x: There was an error updating the infraction.")
+ return
# Get information about the infraction's user
user_id = updated_infraction['user']
@@ -1105,7 +1083,9 @@ class Moderation(Scheduler):
# endregion
# region: Utility functions
- def schedule_expiration(self, loop: asyncio.AbstractEventLoop, infraction_object: dict) -> None:
+ def schedule_expiration(
+ self, loop: asyncio.AbstractEventLoop, infraction_object: Dict[str, Union[str, int, bool]]
+ ) -> None:
"""Schedules a task to expire a temporary infraction."""
infraction_id = infraction_object["id"]
if infraction_id in self.scheduled_tasks:
@@ -1125,7 +1105,7 @@ class Moderation(Scheduler):
log.debug(f"Unscheduled {infraction_id}.")
del self.scheduled_tasks[infraction_id]
- async def _scheduled_task(self, infraction_object: dict) -> None:
+ async def _scheduled_task(self, infraction_object: Dict[str, Union[str, int, bool]]) -> None:
"""
Marks an infraction expired after the delay from time of scheduling to time of expiration.
@@ -1153,7 +1133,7 @@ class Moderation(Scheduler):
icon_url=Icons.user_unmute
)
- async def _deactivate_infraction(self, infraction_object: dict) -> None:
+ async def _deactivate_infraction(self, infraction_object: Dict[str, Union[str, int, bool]]) -> None:
"""
A co-routine which marks an infraction as inactive on the website.
@@ -1173,14 +1153,17 @@ class Moderation(Scheduler):
log.warning(f"Failed to un-mute user: {user_id} (not found)")
elif infraction_type == "ban":
user: Object = Object(user_id)
- await guild.unban(user)
+ try:
+ await guild.unban(user)
+ except NotFound:
+ log.info(f"Tried to unban user `{user_id}`, but Discord does not have an active ban registered.")
await self.bot.api_client.patch(
'bot/infractions/' + str(infraction_object['id']),
json={"active": False}
)
- def _infraction_to_string(self, infraction_object: dict) -> str:
+ def _infraction_to_string(self, infraction_object: Dict[str, Union[str, int, bool]]) -> str:
"""Convert the infraction object to a string representation."""
actor_id = infraction_object["actor"]
guild: Guild = self.bot.get_guild(constants.Guild.id)
@@ -1188,6 +1171,11 @@ class Moderation(Scheduler):
active = infraction_object["active"]
user_id = infraction_object["user"]
hidden = infraction_object["hidden"]
+ created = datetime.fromisoformat(infraction_object["inserted_at"]).strftime("%Y-%m-%d %H:%M")
+ if infraction_object["expires_at"] is None:
+ expires = "*Permanent*"
+ else:
+ expires = datetime.fromisoformat(infraction_object["expires_at"]).strftime("%Y-%m-%d %H:%M")
lines = textwrap.dedent(f"""
{"**===============**" if active else "==============="}
@@ -1196,8 +1184,8 @@ class Moderation(Scheduler):
Type: **{infraction_object["type"]}**
Shadow: {hidden}
Reason: {infraction_object["reason"] or "*None*"}
- Created: {infraction_object["inserted_at"]}
- Expires: {infraction_object["expires_at"] or "*Permanent*"}
+ Created: {created}
+ Expires: {expires}
Actor: {actor.mention if actor else actor_id}
ID: `{infraction_object["id"]}`
{"**===============**" if active else "==============="}
@@ -1206,13 +1194,16 @@ class Moderation(Scheduler):
return lines.strip()
async def notify_infraction(
- self, user: Union[User, Member], infr_type: str,
- expires_at: Union[datetime, str] = 'N/A', reason: str = "No reason provided."
+ self,
+ user: Union[User, Member],
+ infr_type: str,
+ expires_at: Union[datetime, str] = 'N/A',
+ reason: str = "No reason provided."
) -> bool:
"""
Attempt to notify a user, via DM, of their fresh infraction.
- Optionally returns a boolean indicator of whether the DM was successful.
+ Returns a boolean indicator of whether the DM was successful.
"""
if isinstance(expires_at, datetime):
expires_at = expires_at.strftime('%c')
@@ -1237,8 +1228,11 @@ class Moderation(Scheduler):
return await self.send_private_embed(user, embed)
async def notify_pardon(
- self, user: Union[User, Member], title: str, content: str,
- icon_url: str = Icons.user_verified
+ self,
+ user: Union[User, Member],
+ title: str,
+ content: str,
+ icon_url: str = Icons.user_verified
) -> bool:
"""
Attempt to notify a user, via DM, of their expired infraction.
@@ -1261,7 +1255,7 @@ class Moderation(Scheduler):
Returns a boolean indicator of DM success.
"""
# sometimes `user` is a `discord.Object`, so let's make it a proper user.
- user = await self.bot.get_user_info(user.id)
+ user = await self.bot.fetch_user(user.id)
try:
await user.send(embed=embed)
@@ -1288,13 +1282,16 @@ class Moderation(Scheduler):
# endregion
- async def __error(self, ctx: Context, error: Exception) -> None:
+ @staticmethod
+ async def cog_command_error(ctx: Context, error: Exception) -> None:
"""Send a notification to the invoking context on a Union failure."""
if isinstance(error, BadUnionArgument):
if User in error.converters:
await ctx.send(str(error.errors[0]))
+ error.handled = True
- async def respect_role_hierarchy(self, ctx: Context, target: UserTypes, infr_type: str) -> bool:
+ @staticmethod
+ async def respect_role_hierarchy(ctx: Context, target: UserTypes, infr_type: str) -> bool:
"""
Check if the highest role of the invoking member is greater than that of the target member.
diff --git a/bot/cogs/modlog.py b/bot/cogs/modlog.py
index d3ff406d8..d76804c34 100644
--- a/bot/cogs/modlog.py
+++ b/bot/cogs/modlog.py
@@ -11,7 +11,7 @@ from discord import (
RawMessageUpdateEvent, Role, TextChannel, User, VoiceChannel
)
from discord.abc import GuildChannel
-from discord.ext.commands import Bot
+from discord.ext.commands import Bot, Cog
from bot.constants import (
Channels, Colours, Emojis, Event, Guild as GuildConstant, Icons, URLs
@@ -24,11 +24,11 @@ GUILD_CHANNEL = Union[CategoryChannel, TextChannel, VoiceChannel]
CHANNEL_CHANGES_UNSUPPORTED = ("permissions",)
CHANNEL_CHANGES_SUPPRESSED = ("_overwrites", "position")
-MEMBER_CHANGES_SUPPRESSED = ("activity", "status")
+MEMBER_CHANGES_SUPPRESSED = ("status", "activities", "_client_status")
ROLE_CHANGES_UNSUPPORTED = ("colour", "permissions")
-class ModLog:
+class ModLog(Cog, name="ModLog"):
"""Logging for server events and staff actions."""
def __init__(self, bot: Bot):
@@ -87,7 +87,7 @@ class ModLog:
additional_embeds_msg: Optional[str] = None,
timestamp_override: Optional[datetime] = None,
footer: Optional[str] = None,
- ) -> None:
+ ) -> Optional[Message]:
"""Generate log embed and send to logging channel."""
embed = Embed(description=text)
@@ -120,6 +120,7 @@ class ModLog:
return await self.bot.get_context(log_message) # Optionally return for use with antispam
+ @Cog.listener()
async def on_guild_channel_create(self, channel: GUILD_CHANNEL) -> None:
"""Log channel create event to mod log."""
if channel.guild.id != GuildConstant.id:
@@ -145,6 +146,7 @@ class ModLog:
await self.send_log_message(Icons.hash_green, Colour(Colours.soft_green), title, message)
+ @Cog.listener()
async def on_guild_channel_delete(self, channel: GUILD_CHANNEL) -> None:
"""Log channel delete event to mod log."""
if channel.guild.id != GuildConstant.id:
@@ -167,6 +169,7 @@ class ModLog:
title, message
)
+ @Cog.listener()
async def on_guild_channel_update(self, before: GUILD_CHANNEL, after: GuildChannel) -> None:
"""Log channel update event to mod log."""
if before.guild.id != GuildConstant.id:
@@ -226,6 +229,7 @@ class ModLog:
"Channel updated", message
)
+ @Cog.listener()
async def on_guild_role_create(self, role: Role) -> None:
"""Log role create event to mod log."""
if role.guild.id != GuildConstant.id:
@@ -236,6 +240,7 @@ class ModLog:
"Role created", f"`{role.id}`"
)
+ @Cog.listener()
async def on_guild_role_delete(self, role: Role) -> None:
"""Log role delete event to mod log."""
if role.guild.id != GuildConstant.id:
@@ -246,6 +251,7 @@ class ModLog:
"Role removed", f"{role.name} (`{role.id}`)"
)
+ @Cog.listener()
async def on_guild_role_update(self, before: Role, after: Role) -> None:
"""Log role update event to mod log."""
if before.guild.id != GuildConstant.id:
@@ -298,6 +304,7 @@ class ModLog:
"Role updated", message
)
+ @Cog.listener()
async def on_guild_update(self, before: Guild, after: Guild) -> None:
"""Log guild update event to mod log."""
if before.id != GuildConstant.id:
@@ -348,6 +355,7 @@ class ModLog:
thumbnail=after.icon_url_as(format="png")
)
+ @Cog.listener()
async def on_member_ban(self, guild: Guild, member: Union[Member, User]) -> None:
"""Log ban event to mod log."""
if guild.id != GuildConstant.id:
@@ -364,6 +372,7 @@ class ModLog:
channel_id=Channels.modlog
)
+ @Cog.listener()
async def on_member_join(self, member: Member) -> None:
"""Log member join event to user log."""
if member.guild.id != GuildConstant.id:
@@ -385,6 +394,7 @@ class ModLog:
channel_id=Channels.userlog
)
+ @Cog.listener()
async def on_member_remove(self, member: Member) -> None:
"""Log member leave event to user log."""
if member.guild.id != GuildConstant.id:
@@ -401,6 +411,7 @@ class ModLog:
channel_id=Channels.userlog
)
+ @Cog.listener()
async def on_member_unban(self, guild: Guild, member: User) -> None:
"""Log member unban event to mod log."""
if guild.id != GuildConstant.id:
@@ -417,6 +428,7 @@ class ModLog:
channel_id=Channels.modlog
)
+ @Cog.listener()
async def on_member_update(self, before: Member, after: Member) -> None:
"""Log member update event to user log."""
if before.guild.id != GuildConstant.id:
@@ -507,6 +519,7 @@ class ModLog:
channel_id=Channels.userlog
)
+ @Cog.listener()
async def on_message_delete(self, message: Message) -> None:
"""Log message delete event to message change log."""
channel = message.channel
@@ -539,19 +552,22 @@ class ModLog:
"\n"
)
+ if message.attachments:
+ # Prepend the message metadata with the number of attachments
+ response = f"**Attachments:** {len(message.attachments)}\n" + response
+
# Shorten the message content if necessary
content = message.clean_content
remaining_chars = 2040 - len(response)
if len(content) > remaining_chars:
- content = content[:remaining_chars] + "..."
+ botlog_url = await self.upload_log(messages=[message], actor_id=message.author.id)
+ ending = f"\n\nMessage truncated, [full message here]({botlog_url})."
+ truncation_point = remaining_chars - len(ending)
+ content = f"{content[:truncation_point]}...{ending}"
response += f"{content}"
- if message.attachments:
- # Prepend the message metadata with the number of attachments
- response = f"**Attachments:** {len(message.attachments)}\n" + response
-
await self.send_log_message(
Icons.message_delete, Colours.soft_red,
"Message deleted",
@@ -559,6 +575,7 @@ class ModLog:
channel_id=Channels.message_log
)
+ @Cog.listener()
async def on_raw_message_delete(self, event: RawMessageDeleteEvent) -> None:
"""Log raw message delete event to message change log."""
if event.guild_id != GuildConstant.id or event.channel_id in GuildConstant.ignored:
@@ -599,6 +616,7 @@ class ModLog:
channel_id=Channels.message_log
)
+ @Cog.listener()
async def on_message_edit(self, before: Message, after: Message) -> None:
"""Log message edit event to message change log."""
if (
@@ -673,11 +691,12 @@ class ModLog:
channel_id=Channels.message_log, timestamp_override=after.edited_at
)
+ @Cog.listener()
async def on_raw_message_edit(self, event: RawMessageUpdateEvent) -> None:
"""Log raw message edit event to message change log."""
try:
channel = self.bot.get_channel(int(event.data["channel_id"]))
- message = await channel.get_message(event.message_id)
+ message = await channel.fetch_message(event.message_id)
except NotFound: # Was deleted before we got the event
return
diff --git a/bot/cogs/off_topic_names.py b/bot/cogs/off_topic_names.py
index 3849d3d59..a81b783d6 100644
--- a/bot/cogs/off_topic_names.py
+++ b/bot/cogs/off_topic_names.py
@@ -1,11 +1,12 @@
import asyncio
+import difflib
import logging
from datetime import datetime, timedelta
from discord import Colour, Embed
-from discord.ext.commands import BadArgument, Bot, Context, Converter, group
+from discord.ext.commands import BadArgument, Bot, Cog, Context, Converter, group
-from bot.constants import Channels, Keys, MODERATION_ROLES
+from bot.constants import Channels, MODERATION_ROLES
from bot.decorators import with_role
from bot.pagination import LinePaginator
@@ -38,7 +39,7 @@ class OffTopicName(Converter):
return argument.translate(table)
-async def update_names(bot: Bot, headers: dict) -> None:
+async def update_names(bot: Bot) -> None:
"""
The background updater task that performs a channel name update daily.
@@ -69,23 +70,23 @@ async def update_names(bot: Bot, headers: dict) -> None:
)
-class OffTopicNames:
+class OffTopicNames(Cog):
"""Commands related to managing the off-topic category channel names."""
def __init__(self, bot: Bot):
self.bot = bot
- self.headers = {"X-API-KEY": Keys.site_api}
self.updater_task = None
- def __cleanup(self) -> None:
- """Cancel any leftover running updater task."""
+ def cog_unload(self) -> None:
+ """Cancel any running updater tasks on cog unload."""
if self.updater_task is not None:
self.updater_task.cancel()
+ @Cog.listener()
async def on_ready(self) -> None:
"""Start off-topic channel updating event loop if it hasn't already started."""
if self.updater_task is None:
- coro = update_names(self.bot, self.headers)
+ coro = update_names(self.bot)
self.updater_task = self.bot.loop.create_task(coro)
@group(name='otname', aliases=('otnames', 'otn'), invoke_without_command=True)
@@ -96,25 +97,31 @@ class OffTopicNames:
@otname_group.command(name='add', aliases=('a',))
@with_role(*MODERATION_ROLES)
- async def add_command(self, ctx: Context, name: OffTopicName) -> None:
+ async def add_command(self, ctx: Context, *names: OffTopicName) -> None:
"""Adds a new off-topic name to the rotation."""
+ # Chain multiple words to a single one
+ name = "-".join(names)
+
await self.bot.api_client.post(f'bot/off-topic-channel-names', params={'name': name})
log.info(
f"{ctx.author.name}#{ctx.author.discriminator}"
f" added the off-topic channel name '{name}"
)
- await ctx.send(":ok_hand:")
+ await ctx.send(f":ok_hand: Added `{name}` to the names list.")
@otname_group.command(name='delete', aliases=('remove', 'rm', 'del', 'd'))
@with_role(*MODERATION_ROLES)
- async def delete_command(self, ctx: Context, name: OffTopicName) -> None:
+ async def delete_command(self, ctx: Context, *names: OffTopicName) -> None:
"""Removes a off-topic name from the rotation."""
+ # Chain multiple words to a single one
+ name = "-".join(names)
+
await self.bot.api_client.delete(f'bot/off-topic-channel-names/{name}')
log.info(
f"{ctx.author.name}#{ctx.author.discriminator}"
f" deleted the off-topic channel name '{name}"
)
- await ctx.send(":ok_hand:")
+ await ctx.send(f":ok_hand: Removed `{name}` from the names list.")
@otname_group.command(name='list', aliases=('l',))
@with_role(*MODERATION_ROLES)
@@ -136,6 +143,25 @@ class OffTopicNames:
embed.description = "Hmmm, seems like there's nothing here yet."
await ctx.send(embed=embed)
+ @otname_group.command(name='search', aliases=('s',))
+ @with_role(*MODERATION_ROLES)
+ async def search_command(self, ctx: Context, *, query: OffTopicName) -> None:
+ """Search for an off-topic name."""
+ result = await self.bot.api_client.get('bot/off-topic-channel-names')
+ in_matches = {name for name in result if query in name}
+ close_matches = difflib.get_close_matches(query, result, n=10, cutoff=0.70)
+ lines = sorted(f"• {name}" for name in in_matches.union(close_matches))
+ embed = Embed(
+ title=f"Query results",
+ colour=Colour.blue()
+ )
+
+ if lines:
+ await LinePaginator.paginate(lines, ctx, embed, max_size=400, empty=False)
+ else:
+ embed.description = "Nothing found."
+ await ctx.send(embed=embed)
+
def setup(bot: Bot) -> None:
"""Off topic names cog load."""
diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py
index 69d4adf76..fa66660e2 100644
--- a/bot/cogs/reddit.py
+++ b/bot/cogs/reddit.py
@@ -5,7 +5,7 @@ import textwrap
from datetime import datetime, timedelta
from discord import Colour, Embed, TextChannel
-from discord.ext.commands import Bot, Context, group
+from discord.ext.commands import Bot, Cog, Context, group
from bot.constants import Channels, ERROR_REPLIES, Reddit as RedditConfig, STAFF_ROLES
from bot.converters import Subreddit
@@ -15,7 +15,7 @@ from bot.pagination import LinePaginator
log = logging.getLogger(__name__)
-class Reddit:
+class Reddit(Cog):
"""Track subreddit posts and show detailed statistics about them."""
HEADERS = {"User-Agent": "Discord Bot: PythonDiscord (https://pythondiscord.com/)"}
@@ -252,9 +252,10 @@ class Reddit:
max_lines=15
)
+ @Cog.listener()
async def on_ready(self) -> None:
"""Initiate reddit post event loop."""
- self.reddit_channel = self.bot.get_channel(Channels.reddit)
+ self.reddit_channel = await self.bot.fetch_channel(Channels.reddit)
if self.reddit_channel is not None:
if self.new_posts_task is None:
diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py
index d9f6f6536..ff0a6eb1a 100644
--- a/bot/cogs/reminders.py
+++ b/bot/cogs/reminders.py
@@ -7,7 +7,7 @@ from operator import itemgetter
from dateutil.relativedelta import relativedelta
from discord import Colour, Embed
-from discord.ext.commands import Bot, Context, group
+from discord.ext.commands import Bot, Cog, Context, group
from bot.constants import Channels, Icons, NEGATIVE_REPLIES, POSITIVE_REPLIES, STAFF_ROLES
from bot.converters import ExpirationDate
@@ -22,13 +22,14 @@ WHITELISTED_CHANNELS = (Channels.bot,)
MAXIMUM_REMINDERS = 5
-class Reminders(Scheduler):
+class Reminders(Scheduler, Cog):
"""Provide in-channel reminder functionality."""
def __init__(self, bot: Bot):
self.bot = bot
super().__init__()
+ @Cog.listener()
async def on_ready(self) -> None:
"""Reschedule all current reminders."""
response = await self.bot.api_client.get(
diff --git a/bot/cogs/security.py b/bot/cogs/security.py
index 7ada9a4f6..c2c6356d3 100644
--- a/bot/cogs/security.py
+++ b/bot/cogs/security.py
@@ -1,11 +1,12 @@
import logging
+from typing import Union
-from discord.ext.commands import Bot, Context
+from discord.ext.commands import Bot, Cog, Context, NoPrivateMessage
log = logging.getLogger(__name__)
-class Security:
+class Security(Cog):
"""Security-related helpers."""
def __init__(self, bot: Bot):
@@ -17,9 +18,11 @@ class Security:
"""Check if Context instance author is not a bot."""
return not ctx.author.bot
- def check_on_guild(self, ctx: Context) -> bool:
+ def check_on_guild(self, ctx: Context) -> Union[bool, None]:
"""Check if Context instance has a guild attribute."""
- return ctx.guild is not None
+ if ctx.guild is None:
+ raise NoPrivateMessage("This command cannot be used in private messages.")
+ return True
def setup(bot: Bot) -> None:
diff --git a/bot/cogs/site.py b/bot/cogs/site.py
index a941f27a7..aadc9c632 100644
--- a/bot/cogs/site.py
+++ b/bot/cogs/site.py
@@ -1,7 +1,7 @@
import logging
from discord import Colour, Embed
-from discord.ext.commands import Bot, Context, group
+from discord.ext.commands import Bot, Cog, Context, group
from bot.constants import Channels, STAFF_ROLES, URLs
from bot.decorators import redirect_output
@@ -9,10 +9,10 @@ from bot.pagination import LinePaginator
log = logging.getLogger(__name__)
-INFO_URL = f"{URLs.site_schema}{URLs.site}/info"
+PAGES_URL = f"{URLs.site_schema}{URLs.site}/pages"
-class Site:
+class Site(Cog):
"""Commands for linking to different parts of the site."""
def __init__(self, bot: Bot):
@@ -43,15 +43,18 @@ class Site:
@site_group.command(name="resources")
async def site_resources(self, ctx: Context) -> None:
"""Info about the site's Resources page."""
- url = f"{INFO_URL}/resources"
+ learning_url = f"{PAGES_URL}/resources"
+ tools_url = f"{PAGES_URL}/tools"
- embed = Embed(title="Resources")
- embed.set_footer(text=url)
+ embed = Embed(title="Resources & Tools")
+ embed.set_footer(text=f"{learning_url} | {tools_url}")
embed.colour = Colour.blurple()
embed.description = (
- f"The [Resources page]({url}) on our website contains a "
+ f"The [Resources page]({learning_url}) on our website contains a "
"list of hand-selected goodies that we regularly recommend "
- "to both beginners and experts."
+ f"to both beginners and experts. The [Tools page]({tools_url}) "
+ "contains a couple of the most popular tools for programming in "
+ "Python."
)
await ctx.send(embed=embed)
@@ -59,9 +62,9 @@ class Site:
@site_group.command(name="help")
async def site_help(self, ctx: Context) -> None:
"""Info about the site's Getting Help page."""
- url = f"{INFO_URL}/help"
+ url = f"{PAGES_URL}/asking-good-questions"
- embed = Embed(title="Getting Help")
+ embed = Embed(title="Asking Good Questions")
embed.set_footer(text=url)
embed.colour = Colour.blurple()
embed.description = (
@@ -75,7 +78,7 @@ class Site:
@site_group.command(name="faq")
async def site_faq(self, ctx: Context) -> None:
"""Info about the site's FAQ page."""
- url = f"{INFO_URL}/faq"
+ url = f"{PAGES_URL}/frequently-asked-questions"
embed = Embed(title="FAQ")
embed.set_footer(text=url)
@@ -94,13 +97,13 @@ class Site:
async def site_rules(self, ctx: Context, *rules: int) -> None:
"""Provides a link to the `rules` endpoint of the website, or displays specific rule(s), if requested."""
rules_embed = Embed(title='Rules', color=Colour.blurple())
- rules_embed.url = f"{URLs.site_schema}{URLs.site}/about/rules"
+ rules_embed.url = f"{PAGES_URL}/rules"
if not rules:
# Rules were not submitted. Return the default description.
rules_embed.description = (
"The rules and guidelines that apply to this community can be found on"
- " our [rules page](https://pythondiscord.com/about/rules). We expect"
+ f" our [rules page]({PAGES_URL}/rules). We expect"
" all members of the community to have read and understood these."
)
diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py
index 5a5e26655..927afe51b 100644
--- a/bot/cogs/snekbox.py
+++ b/bot/cogs/snekbox.py
@@ -1,18 +1,14 @@
import datetime
import logging
-import random
import re
import textwrap
from signal import Signals
from typing import Optional, Tuple
-from discord import Colour, Embed
-from discord.ext.commands import (
- Bot, CommandError, Context, NoPrivateMessage, command, guild_only
-)
+from discord.ext.commands import Bot, Cog, Context, command, guild_only
-from bot.constants import Channels, ERROR_REPLIES, NEGATIVE_REPLIES, STAFF_ROLES, URLs
-from bot.decorators import InChannelCheckFailure, in_channel
+from bot.constants import Channels, STAFF_ROLES, URLs
+from bot.decorators import in_channel
from bot.utils.messages import wait_for_deletion
@@ -40,7 +36,7 @@ RAW_CODE_REGEX = re.compile(
MAX_PASTE_LEN = 1000
-class Snekbox:
+class Snekbox(Cog):
"""Safe evaluation of Python code using Snekbox."""
def __init__(self, bot: Bot):
@@ -222,28 +218,6 @@ class Snekbox:
finally:
del self.jobs[ctx.author.id]
- @eval_command.error
- async def eval_command_error(self, ctx: Context, error: CommandError) -> None:
- """Eval commands error handler."""
- embed = Embed(colour=Colour.red())
-
- if isinstance(error, NoPrivateMessage):
- embed.title = random.choice(NEGATIVE_REPLIES)
- embed.description = "You're not allowed to use this command in private messages."
- await ctx.send(embed=embed)
-
- elif isinstance(error, InChannelCheckFailure):
- embed.title = random.choice(NEGATIVE_REPLIES)
- embed.description = str(error)
- await ctx.send(embed=embed)
-
- else:
- original_error = getattr(error, 'original', "no original error")
- log.error(f"Unhandled error in snekbox eval: {error} ({original_error})")
- embed.title = random.choice(ERROR_REPLIES)
- embed.description = "Some unhandled error occurred. Sorry for that!"
- await ctx.send(embed=embed)
-
def setup(bot: Bot) -> None:
"""Snekbox cog load."""
diff --git a/bot/cogs/superstarify/__init__.py b/bot/cogs/superstarify/__init__.py
index bd5211102..7a29acdb8 100644
--- a/bot/cogs/superstarify/__init__.py
+++ b/bot/cogs/superstarify/__init__.py
@@ -4,7 +4,7 @@ from datetime import datetime
from discord import Colour, Embed, Member
from discord.errors import Forbidden
-from discord.ext.commands import Bot, Context, command
+from discord.ext.commands import Bot, Cog, Context, command
from bot.cogs.moderation import Moderation
from bot.cogs.modlog import ModLog
@@ -15,10 +15,10 @@ from bot.decorators import with_role
from bot.utils.moderation import post_infraction
log = logging.getLogger(__name__)
-NICKNAME_POLICY_URL = "https://pythondiscord.com/about/rules#nickname-policy"
+NICKNAME_POLICY_URL = "https://pythondiscord.com/pages/rules/#wiki-toc-nickname-policy"
-class Superstarify:
+class Superstarify(Cog):
"""A set of commands to moderate terrible nicknames."""
def __init__(self, bot: Bot):
@@ -34,6 +34,7 @@ class Superstarify:
"""Get currently loaded ModLog cog instance."""
return self.bot.get_cog("ModLog")
+ @Cog.listener()
async def on_member_update(self, before: Member, after: Member) -> None:
"""
This event will trigger when someone changes their name.
@@ -91,6 +92,7 @@ class Superstarify:
"to DM them, and a discord.errors.Forbidden error was incurred."
)
+ @Cog.listener()
async def on_member_join(self, member: Member) -> None:
"""
This event will trigger when someone (re)joins the server.
@@ -102,7 +104,7 @@ class Superstarify:
'bot/infractions',
params={
'active': 'true',
- 'type': 'superstarify',
+ 'type': 'superstar',
'user__id': member.id
}
)
diff --git a/bot/cogs/sync/cog.py b/bot/cogs/sync/cog.py
index 928ffa418..b75fb26cd 100644
--- a/bot/cogs/sync/cog.py
+++ b/bot/cogs/sync/cog.py
@@ -3,7 +3,7 @@ from typing import Callable, Iterable
from discord import Guild, Member, Role
from discord.ext import commands
-from discord.ext.commands import Bot, Context
+from discord.ext.commands import Bot, Cog, Context
from bot import constants
from bot.api import ResponseCodeError
@@ -12,7 +12,7 @@ from bot.cogs.sync import syncers
log = logging.getLogger(__name__)
-class Sync:
+class Sync(Cog):
"""Captures relevant events and sends them to the site."""
# The server to synchronize events on.
@@ -29,6 +29,7 @@ class Sync:
def __init__(self, bot: Bot) -> None:
self.bot = bot
+ @Cog.listener()
async def on_ready(self) -> None:
"""Syncs the roles/users of the guild with the database."""
guild = self.bot.get_guild(self.SYNC_SERVER_ID)
@@ -47,6 +48,7 @@ class Sync:
f"deleted `{total_deleted}`."
)
+ @Cog.listener()
async def on_guild_role_create(self, role: Role) -> None:
"""Adds newly create role to the database table over the API."""
await self.bot.api_client.post(
@@ -60,10 +62,12 @@ class Sync:
}
)
+ @Cog.listener()
async def on_guild_role_delete(self, role: Role) -> None:
"""Deletes role from the database when it's deleted from the guild."""
await self.bot.api_client.delete(f'bot/roles/{role.id}')
+ @Cog.listener()
async def on_guild_role_update(self, before: Role, after: Role) -> None:
"""Syncs role with the database if any of the stored attributes were updated."""
if (
@@ -83,6 +87,7 @@ class Sync:
}
)
+ @Cog.listener()
async def on_member_join(self, member: Member) -> None:
"""
Adds a new user or updates existing user to the database when a member joins the guild.
@@ -118,7 +123,8 @@ class Sync:
# If we got `404`, the user is new. Create them.
await self.bot.api_client.post('bot/users', json=packed)
- async def on_member_leave(self, member: Member) -> None:
+ @Cog.listener()
+ async def on_member_remove(self, member: Member) -> None:
"""Updates the user information when a member leaves the guild."""
await self.bot.api_client.put(
f'bot/users/{member.id}',
@@ -132,6 +138,7 @@ class Sync:
}
)
+ @Cog.listener()
async def on_member_update(self, before: Member, after: Member) -> None:
"""Updates the user information if any of relevant attributes have changed."""
if (
diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py
index 3a93f0d47..660620284 100644
--- a/bot/cogs/tags.py
+++ b/bot/cogs/tags.py
@@ -2,9 +2,9 @@ import logging
import time
from discord import Colour, Embed
-from discord.ext.commands import Bot, Context, group
+from discord.ext.commands import Bot, Cog, Context, group
-from bot.constants import Channels, Cooldowns, Keys, MODERATION_ROLES, Roles
+from bot.constants import Channels, Cooldowns, MODERATION_ROLES, Roles
from bot.converters import TagContentConverter, TagNameConverter
from bot.decorators import with_role
from bot.pagination import LinePaginator
@@ -19,13 +19,12 @@ TEST_CHANNELS = (
)
-class Tags:
+class Tags(Cog):
"""Save new tags and fetch existing tags."""
def __init__(self, bot: Bot):
self.bot = bot
self.tag_cooldowns = {}
- self.headers = {"Authorization": f"Token {Keys.site_api}"}
@group(name='tags', aliases=('tag', 't'), hidden=True, invoke_without_command=True)
async def tags_group(self, ctx: Context, *, tag_name: TagNameConverter = None) -> None:
@@ -67,7 +66,7 @@ class Tags:
"time": time.time(),
"channel": ctx.channel.id
}
- await ctx.send(embed=Embed.from_data(tag['embed']))
+ await ctx.send(embed=Embed.from_dict(tag['embed']))
else:
tags = await self.bot.api_client.get('bot/tags')
diff --git a/bot/cogs/token_remover.py b/bot/cogs/token_remover.py
index 7e9f5ef84..7dd0afbbd 100644
--- a/bot/cogs/token_remover.py
+++ b/bot/cogs/token_remover.py
@@ -6,7 +6,7 @@ import struct
from datetime import datetime
from discord import Colour, Message
-from discord.ext.commands import Bot
+from discord.ext.commands import Bot, Cog
from discord.utils import snowflake_time
from bot.cogs.modlog import ModLog
@@ -26,17 +26,15 @@ DELETION_MESSAGE_TEMPLATE = (
DISCORD_EPOCH_TIMESTAMP = datetime(2017, 1, 1)
TOKEN_EPOCH = 1_293_840_000
TOKEN_RE = re.compile(
- r"(?<=(\"|'))" # Lookbehind: Only match if there's a double or single quote in front
r"[^\s\.]+" # Matches token part 1: The user ID string, encoded as base64
r"\." # Matches a literal dot between the token parts
r"[^\s\.]+" # Matches token part 2: The creation timestamp, as an integer
r"\." # Matches a literal dot between the token parts
r"[^\s\.]+" # Matches token part 3: The HMAC, unused by us, but check that it isn't empty
- r"(?=(\"|'))" # Lookahead: Only match if there's a double or single quote after
)
-class TokenRemover:
+class TokenRemover(Cog):
"""Scans messages for potential discord.py bot tokens and removes them."""
def __init__(self, bot: Bot):
@@ -47,6 +45,7 @@ class TokenRemover:
"""Get currently loaded ModLog cog instance."""
return self.bot.get_cog("ModLog")
+ @Cog.listener()
async def on_message(self, msg: Message) -> None:
"""
Check each message for a string that matches Discord's token pattern.
diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py
index 09e8f70d6..1539851ea 100644
--- a/bot/cogs/utils.py
+++ b/bot/cogs/utils.py
@@ -1,20 +1,19 @@
import logging
-import random
import re
import unicodedata
from email.parser import HeaderParser
from io import StringIO
from discord import Colour, Embed
-from discord.ext.commands import Bot, CheckFailure, Context, command
+from discord.ext.commands import Bot, Cog, Context, command
-from bot.constants import Channels, NEGATIVE_REPLIES, STAFF_ROLES
-from bot.decorators import InChannelCheckFailure, in_channel
+from bot.constants import Channels, STAFF_ROLES
+from bot.decorators import in_channel
log = logging.getLogger(__name__)
-class Utils:
+class Utils(Cog):
"""A selection of utilities which don't have a clear category."""
def __init__(self, bot: Bot):
@@ -125,14 +124,6 @@ class Utils:
await ctx.send(embed=embed)
- async def __error(self, ctx: Context, error: CheckFailure) -> None:
- """Send Check failure error to invoking context if command is invoked in a blacklisted channel by non-staff."""
- embed = Embed(colour=Colour.red())
- if isinstance(error, InChannelCheckFailure):
- embed.title = random.choice(NEGATIVE_REPLIES)
- embed.description = str(error)
- await ctx.send(embed=embed)
-
def setup(bot: Bot) -> None:
"""Utils cog load."""
diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py
index 2cc372afe..6446dc80b 100644
--- a/bot/cogs/verification.py
+++ b/bot/cogs/verification.py
@@ -1,11 +1,11 @@
import logging
from discord import Message, NotFound, Object
-from discord.ext.commands import Bot, Context, command
+from discord.ext.commands import Bot, Cog, Context, command
from bot.cogs.modlog import ModLog
from bot.constants import Channels, Event, Roles
-from bot.decorators import in_channel, without_role
+from bot.decorators import InChannelCheckFailure, in_channel, without_role
log = logging.getLogger(__name__)
@@ -14,8 +14,8 @@ Hello! Welcome to the server, and thanks for verifying yourself!
For your records, these are the documents you accepted:
-`1)` Our rules, here: <https://pythondiscord.com/about/rules>
-`2)` Our privacy policy, here: <https://pythondiscord.com/about/privacy> - you can find information on how to have \
+`1)` Our rules, here: <https://pythondiscord.com/pages/rules>
+`2)` Our privacy policy, here: <https://pythondiscord.com/pages/privacy> - you can find information on how to have \
your information removed here as well.
Feel free to review them at any point!
@@ -28,7 +28,7 @@ If you'd like to unsubscribe from the announcement notifications, simply send `!
"""
-class Verification:
+class Verification(Cog):
"""User verification and role self-management."""
def __init__(self, bot: Bot):
@@ -39,6 +39,7 @@ class Verification:
"""Get currently loaded ModLog cog instance."""
return self.bot.get_cog("ModLog")
+ @Cog.listener()
async def on_message(self, message: Message) -> None:
"""Check new message event for messages to the checkpoint channel & process."""
if message.author.bot:
@@ -143,7 +144,14 @@ class Verification:
)
@staticmethod
- def __global_check(ctx: Context) -> None:
+ async def cog_command_error(ctx: Context, error: Exception) -> None:
+ """Check for & ignore any InChannelCheckFailure."""
+ if isinstance(error, InChannelCheckFailure):
+ # Do nothing; just ignore this error
+ error.handled = True
+
+ @staticmethod
+ def bot_check(ctx: Context) -> bool:
"""Block any command within the verification channel that is not !accept."""
if ctx.channel.id == Channels.verification:
return ctx.command.name == "accept"
diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/cogs/watchchannels/bigbrother.py
index a4c95d8ad..e191c2dbc 100644
--- a/bot/cogs/watchchannels/bigbrother.py
+++ b/bot/cogs/watchchannels/bigbrother.py
@@ -3,7 +3,7 @@ from collections import ChainMap
from typing import Union
from discord import User
-from discord.ext.commands import Bot, Context, group
+from discord.ext.commands import Bot, Cog, Context, group
from bot.constants import Channels, Roles, Webhooks
from bot.decorators import with_role
@@ -13,7 +13,7 @@ from .watchchannel import WatchChannel, proxy_user
log = logging.getLogger(__name__)
-class BigBrother(WatchChannel):
+class BigBrother(WatchChannel, Cog, name="Big Brother"):
"""Monitors users by relaying their messages to a watch channel to assist with moderation."""
def __init__(self, bot: Bot) -> None:
diff --git a/bot/cogs/watchchannels/talentpool.py b/bot/cogs/watchchannels/talentpool.py
index bea0a8b0a..ffe7693a9 100644
--- a/bot/cogs/watchchannels/talentpool.py
+++ b/bot/cogs/watchchannels/talentpool.py
@@ -4,7 +4,7 @@ from collections import ChainMap
from typing import Union
from discord import Color, Embed, Member, User
-from discord.ext.commands import Bot, Context, group
+from discord.ext.commands import Bot, Cog, Context, group
from bot.api import ResponseCodeError
from bot.constants import Channels, Guild, Roles, Webhooks
@@ -16,7 +16,7 @@ log = logging.getLogger(__name__)
STAFF_ROLES = Roles.owner, Roles.admin, Roles.moderator, Roles.helpers # <- In constants after the merge?
-class TalentPool(WatchChannel):
+class TalentPool(WatchChannel, Cog, name="Talentpool"):
"""Relays messages of helper candidates to a watch channel to observe them."""
def __init__(self, bot: Bot) -> None:
diff --git a/bot/cogs/watchchannels/watchchannel.py b/bot/cogs/watchchannels/watchchannel.py
index 5ca819955..1a3f5b18c 100644
--- a/bot/cogs/watchchannels/watchchannel.py
+++ b/bot/cogs/watchchannels/watchchannel.py
@@ -3,20 +3,20 @@ import datetime
import logging
import re
import textwrap
-from abc import ABC, abstractmethod
+from abc import abstractmethod
from collections import defaultdict, deque
from dataclasses import dataclass
from typing import Optional
import discord
-from discord import Color, Embed, Message, Object, errors
-from discord.ext.commands import BadArgument, Bot, Context
+from discord import Color, Embed, HTTPException, Message, Object, errors
+from discord.ext.commands import BadArgument, Bot, Cog, Context
from bot.api import ResponseCodeError
from bot.cogs.modlog import ModLog
from bot.constants import BigBrother as BigBrotherConfig, Guild as GuildConfig, Icons
from bot.pagination import LinePaginator
-from bot.utils import messages
+from bot.utils import CogABCMeta, messages
from bot.utils.time import time_since
log = logging.getLogger(__name__)
@@ -49,7 +49,7 @@ class MessageHistory:
message_count: int = 0
-class WatchChannel(ABC):
+class WatchChannel(metaclass=CogABCMeta):
"""ABC with functionality for relaying users' messages to a certain channel."""
@abstractmethod
@@ -103,21 +103,14 @@ class WatchChannel(ABC):
"""Starts the watch channel by getting the channel, webhook, and user cache ready."""
await self.bot.wait_until_ready()
- # After updating d.py, this block can be replaced by `fetch_channel` with a try-except
- for attempt in range(1, self.retries+1):
- self.channel = self.bot.get_channel(self.destination)
- if self.channel is None:
- if attempt < self.retries:
- await asyncio.sleep(self.retry_delay)
- else:
- break
- else:
- self.log.error(f"Failed to retrieve the text channel with id {self.destination}")
+ try:
+ self.channel = await self.bot.fetch_channel(self.destination)
+ except HTTPException:
+ self.log.exception(f"Failed to retrieve the text channel with id `{self.destination}`")
- # `get_webhook_info` has been renamed to `fetch_webhook` in newer versions of d.py
try:
- self.webhook = await self.bot.get_webhook_info(self.webhook_id)
- except (discord.HTTPException, discord.NotFound, discord.Forbidden):
+ self.webhook = await self.bot.fetch_webhook(self.webhook_id)
+ except discord.HTTPException:
self.log.exception(f"Failed to fetch webhook with id `{self.webhook_id}`")
if self.channel is None or self.webhook is None:
@@ -174,6 +167,7 @@ class WatchChannel(ABC):
return True
+ @Cog.listener()
async def on_message(self, msg: Message) -> None:
"""Queues up messages sent by watched users."""
if msg.author.id in self.watched_users:
diff --git a/bot/cogs/wolfram.py b/bot/cogs/wolfram.py
index 0093a1615..1aa656a4b 100644
--- a/bot/cogs/wolfram.py
+++ b/bot/cogs/wolfram.py
@@ -4,12 +4,14 @@ from typing import List, Optional, Tuple
from urllib import parse
import discord
+from dateutil.relativedelta import relativedelta
from discord import Embed
from discord.ext import commands
-from discord.ext.commands import Bot, BucketType, Context, check, group
+from discord.ext.commands import Bot, BucketType, Cog, Context, check, group
from bot.constants import Colours, STAFF_ROLES, Wolfram
from bot.pagination import ImagePaginator
+from bot.utils.time import humanize_delta
log = logging.getLogger(__name__)
@@ -64,9 +66,11 @@ def custom_cooldown(*ignore: List[int]) -> check:
if user_rate:
# Can't use api; cause: member limit
+ delta = relativedelta(seconds=int(user_rate))
+ cooldown = humanize_delta(delta)
message = (
"You've used up your limit for Wolfram|Alpha requests.\n"
- f"Cooldown: {int(user_rate)}"
+ f"Cooldown: {cooldown}"
)
await send_embed(ctx, message)
return False
@@ -106,17 +110,27 @@ async def get_pod_pages(ctx: Context, bot: Bot, query: str) -> Optional[List[Tup
result = json["queryresult"]
- if not result["success"]:
- message = f"I couldn't find anything for {query}."
- await send_embed(ctx, message)
- return
-
if result["error"]:
+ # API key not set up correctly
+ if result["error"]["msg"] == "Invalid appid":
+ message = "Wolfram API key is invalid or missing."
+ log.warning(
+ "API key seems to be missing, or invalid when "
+ f"processing a wolfram request: {url_str}, Response: {json}"
+ )
+ await send_embed(ctx, message)
+ return
+
message = "Something went wrong internally with your request, please notify staff!"
log.warning(f"Something went wrong getting a response from wolfram: {url_str}, Response: {json}")
await send_embed(ctx, message)
return
+ if not result["success"]:
+ message = f"I couldn't find anything for {query}."
+ await send_embed(ctx, message)
+ return
+
if not result["numpods"]:
message = "Could not find any results."
await send_embed(ctx, message)
@@ -134,7 +148,7 @@ async def get_pod_pages(ctx: Context, bot: Bot, query: str) -> Optional[List[Tup
return pages
-class Wolfram:
+class Wolfram(Cog):
"""Commands for interacting with the Wolfram|Alpha API."""
def __init__(self, bot: commands.Bot):
@@ -167,6 +181,10 @@ class Wolfram:
message = "No input found"
footer = ""
color = Colours.soft_red
+ elif status == 403:
+ message = "Wolfram API key is invalid or missing."
+ footer = ""
+ color = Colours.soft_red
else:
message = ""
footer = "View original for a bigger picture."
@@ -235,10 +253,12 @@ class Wolfram:
if status == 501:
message = "Failed to get response"
color = Colours.soft_red
-
elif status == 400:
message = "No input found"
color = Colours.soft_red
+ elif response_text == "Error 1: Invalid appid":
+ message = "Wolfram API key is invalid or missing."
+ color = Colours.soft_red
else:
message = response_text
color = Colours.soft_orange
diff --git a/bot/constants.py b/bot/constants.py
index ead26c91d..e1c47889c 100644
--- a/bot/constants.py
+++ b/bot/constants.py
@@ -341,13 +341,15 @@ class Channels(metaclass=YAMLGetter):
help_3: int
help_4: int
help_5: int
+ help_6: int
+ help_7: int
helpers: int
message_log: int
mod_alerts: int
modlog: int
+ off_topic_0: int
off_topic_1: int
off_topic_2: int
- off_topic_3: int
python: int
reddit: int
talent_pool: int
@@ -372,13 +374,12 @@ class Roles(metaclass=YAMLGetter):
announcements: int
champion: int
contributor: int
- developer: int
- devops: int
+ core_developer: int
jammer: int
moderator: int
muted: int
owner: int
- verified: int
+ verified: int # This is the Developers role on PyDis, here named verified for readability reasons.
helpers: int
team_leader: int
@@ -393,8 +394,6 @@ class Guild(metaclass=YAMLGetter):
class Keys(metaclass=YAMLGetter):
section = "keys"
- deploy_bot: str
- deploy_site: str
site_api: str
@@ -410,14 +409,11 @@ class URLs(metaclass=YAMLGetter):
# Misc endpoints
bot_avatar: str
- deploy: str
- gitlab_bot_repo: str
- status: str
+ github_bot_repo: str
# Site endpoints
site: str
site_api: str
- site_clean_api: str
site_superstarify_api: str
site_logs_api: str
site_logs_view: str
diff --git a/bot/converters.py b/bot/converters.py
index af7ecd107..7386187ab 100644
--- a/bot/converters.py
+++ b/bot/converters.py
@@ -71,7 +71,7 @@ class InfractionSearchQuery(Converter):
"""Check if the argument is a Discord user, and if not, falls back to a string."""
try:
maybe_snowflake = arg.strip("<@!>")
- return await ctx.bot.get_user_info(maybe_snowflake)
+ return await ctx.bot.fetch_user(maybe_snowflake)
except (discord.NotFound, discord.HTTPException):
return arg
diff --git a/bot/decorators.py b/bot/decorators.py
index 3600be3bb..ace9346f0 100644
--- a/bot/decorators.py
+++ b/bot/decorators.py
@@ -20,7 +20,11 @@ log = logging.getLogger(__name__)
class InChannelCheckFailure(CheckFailure):
"""In channel check failure exception."""
- pass
+ def __init__(self, *channels: int):
+ self.channels = channels
+ channels_str = ', '.join(f"<#{c_id}>" for c_id in channels)
+
+ super().__init__(f"Sorry, but you may only use this command within {channels_str}.")
def in_channel(*channels: int, bypass_roles: Container[int] = None) -> Callable:
@@ -42,10 +46,7 @@ def in_channel(*channels: int, bypass_roles: Container[int] = None) -> Callable:
log.debug(f"{ctx.author} tried to call the '{ctx.command.name}' command. "
f"The in_channel check failed.")
- channels_str = ', '.join(f"<#{c_id}>" for c_id in channels)
- raise InChannelCheckFailure(
- f"Sorry, but you may only use this command within {channels_str}."
- )
+ raise InChannelCheckFailure(*channels)
return commands.check(predicate)
diff --git a/bot/patches/__init__.py b/bot/patches/__init__.py
new file mode 100644
index 000000000..60f6becaa
--- /dev/null
+++ b/bot/patches/__init__.py
@@ -0,0 +1,6 @@
+"""Subpackage that contains patches for discord.py."""
+from . import message_edited_at
+
+__all__ = [
+ message_edited_at,
+]
diff --git a/bot/patches/message_edited_at.py b/bot/patches/message_edited_at.py
new file mode 100644
index 000000000..6a73af8c9
--- /dev/null
+++ b/bot/patches/message_edited_at.py
@@ -0,0 +1,33 @@
+# flake8: noqa
+"""
+# message_edited_at patch
+
+Date: 2019-09-16
+Author: Scragly
+Added by: Ves Zappa
+
+Due to a bug in our current version of discord.py (1.2.3), the edited_at timestamp of
+`discord.Messages` are not being handled correctly. This patch fixes that until a new
+release of discord.py is released (and we've updated to it).
+"""
+import logging
+
+from discord import message, utils
+
+log = logging.getLogger(__name__)
+
+
+def _handle_edited_timestamp(self, value) -> None:
+ """Helper function that takes care of parsing the edited timestamp."""
+ self._edited_timestamp = utils.parse_time(value)
+
+
+def apply_patch() -> None:
+ """Applies the `edited_at` patch to the `discord.message.Message` class."""
+ message.Message._handle_edited_timestamp = _handle_edited_timestamp
+ message.Message._HANDLERS['edited_timestamp'] = message.Message._handle_edited_timestamp
+ log.info("Patch applied: message_edited_at")
+
+
+if __name__ == "__main__":
+ apply_patch()
diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py
index 141559657..618b90748 100644
--- a/bot/utils/__init__.py
+++ b/bot/utils/__init__.py
@@ -1,5 +1,14 @@
+from abc import ABCMeta
from typing import Any, Generator, Hashable, Iterable
+from discord.ext.commands import CogMeta
+
+
+class CogABCMeta(CogMeta, ABCMeta):
+ """Metaclass for ABCs meant to be implemented as Cogs."""
+
+ pass
+
class CaseInsensitiveDict(dict):
"""
diff --git a/bot/utils/checks.py b/bot/utils/checks.py
index 1f4c1031b..19f64ff9f 100644
--- a/bot/utils/checks.py
+++ b/bot/utils/checks.py
@@ -29,7 +29,7 @@ def without_role_check(ctx: Context, *role_ids: int) -> bool:
"This command is restricted by the without_role decorator. Rejecting request.")
return False
- author_roles = (role.id for role in ctx.author.roles)
+ author_roles = [role.id for role in ctx.author.roles]
check = all(role not in author_roles for role in role_ids)
log.trace(f"{ctx.author} tried to call the '{ctx.command.name}' command. "
f"The result of the without_role check was {check}.")
diff --git a/bot/utils/moderation.py b/bot/utils/moderation.py
index 9ea2db07c..7860f14a1 100644
--- a/bot/utils/moderation.py
+++ b/bot/utils/moderation.py
@@ -1,11 +1,11 @@
import logging
from datetime import datetime
-from typing import Union
+from typing import Optional, Union
-from aiohttp import ClientError
from discord import Member, Object, User
from discord.ext.commands import Context
+from bot.api import ResponseCodeError
from bot.constants import Keys
log = logging.getLogger(__name__)
@@ -21,8 +21,8 @@ async def post_infraction(
expires_at: datetime = None,
hidden: bool = False,
active: bool = True,
-) -> Union[dict, None]:
- """Post infraction to the API."""
+) -> Optional[dict]:
+ """Posts an infraction to the API."""
payload = {
"actor": ctx.message.author.id,
"hidden": hidden,
@@ -36,9 +36,37 @@ async def post_infraction(
try:
response = await ctx.bot.api_client.post('bot/infractions', json=payload)
- except ClientError:
- log.exception("There was an error adding an infraction.")
- await ctx.send(":x: There was an error adding the infraction.")
- return
+ except ResponseCodeError as exp:
+ if exp.status == 400 and 'user' in exp.response_json:
+ log.info(
+ f"{ctx.author} tried to add a {type} infraction to `{user.id}`, "
+ "but that user id was not found in the database."
+ )
+ await ctx.send(f":x: Cannot add infraction, the specified user is not known to the database.")
+ return
+ else:
+ log.exception("An unexpected ResponseCodeError occurred while adding an infraction:")
+ await ctx.send(":x: There was an error adding the infraction.")
+ return
return response
+
+
+async def already_has_active_infraction(ctx: Context, user: Union[Member, Object, User], type: str) -> bool:
+ """Checks if a user already has an active infraction of the given type."""
+ active_infractions = await ctx.bot.api_client.get(
+ 'bot/infractions',
+ params={
+ 'active': 'true',
+ 'type': type,
+ 'user__id': str(user.id)
+ }
+ )
+ if active_infractions:
+ await ctx.send(
+ f":x: According to my records, this user already has a {type} infraction. "
+ f"See infraction **#{active_infractions[0]['id']}**."
+ )
+ return True
+ else:
+ return False
diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py
index 9975b04e0..3dec42480 100644
--- a/bot/utils/scheduling.py
+++ b/bot/utils/scheduling.py
@@ -1,13 +1,15 @@
import asyncio
import contextlib
import logging
-from abc import ABC, abstractmethod
+from abc import abstractmethod
from typing import Coroutine, Dict, Union
+from bot.utils import CogABCMeta
+
log = logging.getLogger(__name__)
-class Scheduler(ABC):
+class Scheduler(metaclass=CogABCMeta):
"""Task scheduler."""
def __init__(self):
diff --git a/config-default.yml b/config-default.yml
index e8ad1d572..403de21ad 100644
--- a/config-default.yml
+++ b/config-default.yml
@@ -95,7 +95,7 @@ guild:
bot: 267659945086812160
checkpoint_test: 422077681434099723
defcon: 464469101889454091
- devlog: &DEVLOG 409308876241108992
+ devlog: &DEVLOG 622895325144940554
devtest: &DEVTEST 414574275865870337
help_0: 303906576991780866
help_1: 303906556754395136
@@ -103,6 +103,8 @@ guild:
help_3: 439702951246692352
help_4: 451312046647148554
help_5: 454941769734422538
+ help_6: 587375753306570782
+ help_7: 587375768556797982
helpers: 385474242440986624
message_log: &MESSAGE_LOG 467752170159079424
mod_alerts: 473092532147060736
@@ -125,8 +127,7 @@ guild:
announcements: 463658397560995840
champion: 430492892331769857
contributor: 295488872404484098
- developer: 352427296948486144
- devops: &DEVOPS_ROLE 409416496733880320
+ core_developer: 587606783669829632
jammer: 423054537079783434
moderator: &MOD_ROLE 267629731250176001
muted: &MUTED_ROLE 277914926603829249
@@ -222,13 +223,10 @@ filter:
- *ADMIN_ROLE
- *MOD_ROLE
- *OWNER_ROLE
- - *DEVOPS_ROLE
- *ROCKSTARS_ROLE
keys:
- deploy_bot: !ENV "DEPLOY_BOT_KEY"
- deploy_site: !ENV "DEPLOY_SITE"
site_api: !ENV "BOT_API_KEY"
@@ -261,11 +259,7 @@ urls:
paste_service: !JOIN [*SCHEMA, *PASTE, "/{key}"]
# Snekbox
- snekbox_eval_api: "http://localhost:8060/eval"
-
- # Env vars
- deploy: !ENV "DEPLOY_URL"
- status: !ENV "STATUS_URL"
+ snekbox_eval_api: "https://snekbox.pythondiscord.com/eval"
# Discord API URLs
discord_api: &DISCORD_API "https://discordapp.com/api/v7/"
@@ -273,7 +267,7 @@ urls:
# Misc URLs
bot_avatar: "https://raw.githubusercontent.com/discord-python/branding/master/logos/logo_circle/logo_circle.png"
- gitlab_bot_repo: "https://gitlab.com/python-discord/projects/bot"
+ github_bot_repo: "https://github.com/python-discord/bot"
anti_spam:
# Clean messages that violate a rule.
diff --git a/scripts/deploy-azure.sh b/scripts/deploy-azure.sh
index af69ab46b..ed4b719e2 100644
--- a/scripts/deploy-azure.sh
+++ b/scripts/deploy-azure.sh
@@ -2,11 +2,11 @@
cd ..
-# Build and deploy on django branch, only if not a pull request
-if [[ ($BUILD_SOURCEBRANCHNAME == 'django') && ($SYSTEM_PULLREQUEST_PULLREQUESTID == '') ]]; then
+# Build and deploy on master branch, only if not a pull request
+if [[ ($BUILD_SOURCEBRANCHNAME == 'master') && ($SYSTEM_PULLREQUEST_PULLREQUESTID == '') ]]; then
echo "Building image"
- docker build -t pythondiscord/bot:django .
+ docker build -t pythondiscord/bot:latest .
echo "Pushing image"
- docker push pythondiscord/bot:django
+ docker push pythondiscord/bot:latest
fi
diff --git a/scripts/deploy.sh b/scripts/deploy.sh
deleted file mode 100644
index 070d0ec26..000000000
--- a/scripts/deploy.sh
+++ /dev/null
@@ -1,32 +0,0 @@
-#!/bin/bash
-
-# Build and deploy on master branch
-if [[ $CI_COMMIT_REF_SLUG == 'master' ]]; then
- echo "Connecting to docker hub"
- echo "$DOCKER_PASSWORD" | docker login -u "$DOCKER_USERNAME" --password-stdin
-
- changed_lines=$(git diff HEAD~1 HEAD docker/base.Dockerfile | wc -l)
-
- if [ $changed_lines != '0' ]; then
- echo "base.Dockerfile was changed"
-
- echo "Building bot base"
- docker build -t pythondiscord/bot-base:latest -f docker/base.Dockerfile .
-
- echo "Pushing image to Docker Hub"
- docker push pythondiscord/bot-base:latest
- else
- echo "base.Dockerfile was not changed, not building"
- fi
-
- echo "Building image"
- docker build -t pythondiscord/bot:latest -f docker/bot.Dockerfile .
-
- echo "Pushing image"
- docker push pythondiscord/bot:latest
-
- echo "Deploying container"
- curl -H "token: $AUTODEPLOY_TOKEN" $AUTODEPLOY_WEBHOOK
-else
- echo "Skipping deploy"
-fi
diff --git a/tests/cogs/test_antispam.py b/tests/cogs/test_antispam.py
new file mode 100644
index 000000000..67900b275
--- /dev/null
+++ b/tests/cogs/test_antispam.py
@@ -0,0 +1,30 @@
+import pytest
+
+from bot.cogs import antispam
+
+
+def test_default_antispam_config_is_valid():
+ validation_errors = antispam.validate_config()
+ assert not validation_errors
+
+
+ ('config', 'expected'),
+ (
+ (
+ {'invalid-rule': {}},
+ {'invalid-rule': "`invalid-rule` is not recognized as an antispam rule."}
+ ),
+ (
+ {'burst': {'interval': 10}},
+ {'burst': "Key `max` is required but not set for rule `burst`"}
+ ),
+ (
+ {'burst': {'max': 10}},
+ {'burst': "Key `interval` is required but not set for rule `burst`"}
+ )
+ )
+)
+def test_invalid_antispam_config_returns_validation_errors(config, expected):
+ validation_errors = antispam.validate_config(config)
+ assert validation_errors == expected
diff --git a/tests/cogs/test_information.py b/tests/cogs/test_information.py
new file mode 100644
index 000000000..85b2d092e
--- /dev/null
+++ b/tests/cogs/test_information.py
@@ -0,0 +1,163 @@
+import asyncio
+import logging
+import textwrap
+from datetime import datetime
+from unittest.mock import MagicMock, patch
+
+import pytest
+from discord import (
+ CategoryChannel,
+ Colour,
+ TextChannel,
+ VoiceChannel,
+)
+
+from bot.cogs import information
+from bot.constants import Emojis
+from bot.decorators import InChannelCheckFailure
+from tests.helpers import AsyncMock
+
+
+def cog(simple_bot):
+ return information.Information(simple_bot)
+
+
+def role(name: str, id_: int):
+ r = MagicMock()
+ r.name = name
+ r.id = id_
+ r.mention = f'&{name}'
+ return r
+
+
+def member(status: str):
+ m = MagicMock()
+ m.status = status
+ return m
+
+
+def ctx(moderator_role, simple_ctx):
+ simple_ctx.author.roles = [moderator_role]
+ simple_ctx.guild.created_at = datetime(2001, 1, 1)
+ simple_ctx.send = AsyncMock()
+ return simple_ctx
+
+
+def test_roles_info_command(cog, ctx):
+ everyone_role = MagicMock()
+ everyone_role.name = '@everyone' # should be excluded in the output
+ ctx.author.roles.append(everyone_role)
+ ctx.guild.roles = ctx.author.roles
+
+ cog.roles_info.can_run = AsyncMock()
+ cog.roles_info.can_run.return_value = True
+
+ coroutine = cog.roles_info.callback(cog, ctx)
+
+ assert asyncio.run(coroutine) is None # no rval
+ ctx.send.assert_called_once()
+ _, kwargs = ctx.send.call_args
+ embed = kwargs.pop('embed')
+ assert embed.title == "Role information"
+ assert embed.colour == Colour.blurple()
+ assert embed.description == f"`{ctx.guild.roles[0].id}` - {ctx.guild.roles[0].mention}\n"
+ assert embed.footer.text == "Total roles: 1"
+
+
+# There is no argument passed in here that we can use to test,
+# so the return value would change constantly.
+@patch('bot.cogs.information.time_since')
+def test_server_info_command(time_since_patch, cog, ctx, moderator_role):
+ time_since_patch.return_value = '2 days ago'
+
+ ctx.guild.created_at = datetime(2001, 1, 1)
+ ctx.guild.features = ('lemons', 'apples')
+ ctx.guild.region = 'The Moon'
+ ctx.guild.roles = [moderator_role]
+ ctx.guild.channels = [
+ TextChannel(
+ state={},
+ guild=ctx.guild,
+ data={'id': 42, 'name': 'lemons-offering', 'position': 22, 'type': 'text'}
+ ),
+ CategoryChannel(
+ state={},
+ guild=ctx.guild,
+ data={'id': 5125, 'name': 'the-lemon-collection', 'position': 22, 'type': 'category'}
+ ),
+ VoiceChannel(
+ state={},
+ guild=ctx.guild,
+ data={'id': 15290, 'name': 'listen-to-lemon', 'position': 22, 'type': 'voice'}
+ )
+ ]
+ ctx.guild.members = [
+ member('online'), member('online'),
+ member('idle'),
+ member('dnd'), member('dnd'), member('dnd'), member('dnd'),
+ member('offline'), member('offline'), member('offline')
+ ]
+ ctx.guild.member_count = 1_234
+ ctx.guild.icon_url = 'a-lemon.png'
+
+ coroutine = cog.server_info.callback(cog, ctx)
+ assert asyncio.run(coroutine) is None # no rval
+
+ time_since_patch.assert_called_once_with(ctx.guild.created_at, precision='days')
+ _, kwargs = ctx.send.call_args
+ embed = kwargs.pop('embed')
+ assert embed.colour == Colour.blurple()
+ assert embed.description == textwrap.dedent(f"""
+ **Server information**
+ Created: {time_since_patch.return_value}
+ Voice region: {ctx.guild.region}
+ Features: {', '.join(ctx.guild.features)}
+
+ **Counts**
+ Members: {ctx.guild.member_count:,}
+ Roles: {len(ctx.guild.roles)}
+ Text: 1
+ Voice: 1
+ Channel categories: 1
+
+ **Members**
+ {Emojis.status_online} 2
+ {Emojis.status_idle} 1
+ {Emojis.status_dnd} 4
+ {Emojis.status_offline} 3
+ """)
+ assert embed.thumbnail.url == 'a-lemon.png'
+
+
+def test_user_info_on_other_users_from_non_moderator(ctx, cog):
+ ctx.author = MagicMock()
+ ctx.author.__eq__.return_value = False
+ ctx.author.roles = []
+ coroutine = cog.user_info.callback(cog, ctx, user='scragly') # skip checks, pass args
+
+ assert asyncio.run(coroutine) is None # no rval
+ ctx.send.assert_called_once_with(
+ "You may not use this command on users other than yourself."
+ )
+
+
+def test_user_info_in_wrong_channel_from_non_moderator(ctx, cog):
+ ctx.author = MagicMock()
+ ctx.author.__eq__.return_value = False
+ ctx.author.roles = []
+
+ coroutine = cog.user_info.callback(cog, ctx)
+ message = 'Sorry, but you may only use this command within <#267659945086812160>.'
+ with pytest.raises(InChannelCheckFailure, match=message):
+ assert asyncio.run(coroutine) is None # no rval
+
+
+def test_setup(simple_bot, caplog):
+ information.setup(simple_bot)
+ simple_bot.add_cog.assert_called_once()
+ [record] = caplog.records
+
+ assert record.message == "Cog loaded: Information"
+ assert record.levelno == logging.INFO
diff --git a/tests/cogs/test_security.py b/tests/cogs/test_security.py
new file mode 100644
index 000000000..1efb460fe
--- /dev/null
+++ b/tests/cogs/test_security.py
@@ -0,0 +1,54 @@
+import logging
+from unittest.mock import MagicMock
+
+import pytest
+from discord.ext.commands import NoPrivateMessage
+
+from bot.cogs import security
+
+
+def cog():
+ bot = MagicMock()
+ return security.Security(bot)
+
+
+def context():
+ return MagicMock()
+
+
+def test_check_additions(cog):
+ cog.bot.check.assert_any_call(cog.check_on_guild)
+ cog.bot.check.assert_any_call(cog.check_not_bot)
+
+
+def test_check_not_bot_for_humans(cog, context):
+ context.author.bot = False
+ assert cog.check_not_bot(context)
+
+
+def test_check_not_bot_for_robots(cog, context):
+ context.author.bot = True
+ assert not cog.check_not_bot(context)
+
+
+def test_check_on_guild_outside_of_guild(cog, context):
+ context.guild = None
+
+ with pytest.raises(NoPrivateMessage, match="This command cannot be used in private messages."):
+ cog.check_on_guild(context)
+
+
+def test_check_on_guild_on_guild(cog, context):
+ context.guild = "lemon's lemonade stand"
+ assert cog.check_on_guild(context)
+
+
+def test_security_cog_load(caplog):
+ bot = MagicMock()
+ security.setup(bot)
+ bot.add_cog.assert_called_once()
+ [record] = caplog.records
+ assert record.message == "Cog loaded: Security"
+ assert record.levelno == logging.INFO
diff --git a/tests/cogs/test_token_remover.py b/tests/cogs/test_token_remover.py
new file mode 100644
index 000000000..9d46b3a05
--- /dev/null
+++ b/tests/cogs/test_token_remover.py
@@ -0,0 +1,133 @@
+import asyncio
+from unittest.mock import MagicMock
+
+import pytest
+from discord import Colour
+
+from bot.cogs.token_remover import (
+ DELETION_MESSAGE_TEMPLATE,
+ TokenRemover,
+ setup as setup_cog,
+)
+from bot.constants import Channels, Colours, Event, Icons
+from tests.helpers import AsyncMock
+
+
+def token_remover():
+ bot = MagicMock()
+ bot.get_cog.return_value = MagicMock()
+ bot.get_cog.return_value.send_log_message = AsyncMock()
+ return TokenRemover(bot=bot)
+
+
+def message():
+ message = MagicMock()
+ message.author.__str__.return_value = 'lemon'
+ message.author.bot = False
+ message.author.avatar_url_as.return_value = 'picture-lemon.png'
+ message.author.id = 42
+ message.author.mention = '@lemon'
+ message.channel.send = AsyncMock()
+ message.channel.mention = '#lemonade-stand'
+ message.content = ''
+ message.delete = AsyncMock()
+ message.id = 555
+ return message
+
+
+ ('content', 'expected'),
+ (
+ ('MTIz', True), # 123
+ ('YWJj', False), # abc
+ )
+)
+def test_is_valid_user_id(content: str, expected: bool):
+ assert TokenRemover.is_valid_user_id(content) is expected
+
+
+ ('content', 'expected'),
+ (
+ ('DN9r_A', True), # stolen from dapi, thanks to the author of the 'token' tag!
+ ('MTIz', False), # 123
+ )
+)
+def test_is_valid_timestamp(content: str, expected: bool):
+ assert TokenRemover.is_valid_timestamp(content) is expected
+
+
+def test_mod_log_property(token_remover):
+ token_remover.bot.get_cog.return_value = 'lemon'
+ assert token_remover.mod_log == 'lemon'
+ token_remover.bot.get_cog.assert_called_once_with('ModLog')
+
+
+def test_ignores_bot_messages(token_remover, message):
+ message.author.bot = True
+ coroutine = token_remover.on_message(message)
+ assert asyncio.run(coroutine) is None
+
+
[email protected]('content', ('', 'lemon wins'))
+def test_ignores_messages_without_tokens(token_remover, message, content):
+ message.content = content
+ coroutine = token_remover.on_message(message)
+ assert asyncio.run(coroutine) is None
+
+
[email protected]('content', ('foo.bar.baz', 'x.y.'))
+def test_ignores_invalid_tokens(token_remover, message, content):
+ message.content = content
+ coroutine = token_remover.on_message(message)
+ assert asyncio.run(coroutine) is None
+
+
+ 'content, censored_token',
+ (
+ ('MTIz.DN9R_A.xyz', 'MTIz.DN9R_A.xxx'),
+ )
+)
+def test_censors_valid_tokens(
+ token_remover, message, content, censored_token, caplog
+):
+ message.content = content
+ coroutine = token_remover.on_message(message)
+ assert asyncio.run(coroutine) is None # still no rval
+
+ # asyncio logs some stuff about its reactor, discard it
+ [_, record] = caplog.records
+ assert record.message == (
+ "Censored a seemingly valid token sent by lemon (`42`) in #lemonade-stand, "
+ f"token was `{censored_token}`"
+ )
+
+ message.delete.assert_called_once_with()
+ message.channel.send.assert_called_once_with(
+ DELETION_MESSAGE_TEMPLATE.format(mention='@lemon')
+ )
+ token_remover.bot.get_cog.assert_called_with('ModLog')
+ message.author.avatar_url_as.assert_called_once_with(static_format='png')
+
+ mod_log = token_remover.bot.get_cog.return_value
+ mod_log.ignore.assert_called_once_with(Event.message_delete, message.id)
+ mod_log.send_log_message.assert_called_once_with(
+ icon_url=Icons.token_removed,
+ colour=Colour(Colours.soft_red),
+ title="Token removed!",
+ text=record.message,
+ thumbnail='picture-lemon.png',
+ channel_id=Channels.mod_alerts
+ )
+
+
+def test_setup(caplog):
+ bot = MagicMock()
+ setup_cog(bot)
+ [record] = caplog.records
+
+ bot.add_cog.assert_called_once()
+ assert record.message == "Cog loaded: TokenRemover"
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 000000000..d3de4484d
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,32 @@
+from unittest.mock import MagicMock
+
+import pytest
+
+from bot.constants import Roles
+from tests.helpers import AsyncMock
+
+
+def moderator_role():
+ mock = MagicMock()
+ mock.id = Roles.moderator
+ mock.name = 'Moderator'
+ mock.mention = f'&{mock.name}'
+ return mock
+
+
+def simple_bot():
+ mock = MagicMock()
+ mock._before_invoke = AsyncMock()
+ mock._after_invoke = AsyncMock()
+ mock.can_run = AsyncMock()
+ mock.can_run.return_value = True
+ return mock
+
+
+def simple_ctx(simple_bot):
+ mock = MagicMock()
+ mock.bot = simple_bot
+ return mock
diff --git a/tests/helpers.py b/tests/helpers.py
new file mode 100644
index 000000000..2908294f7
--- /dev/null
+++ b/tests/helpers.py
@@ -0,0 +1,29 @@
+import asyncio
+import functools
+from unittest.mock import MagicMock
+
+
+__all__ = ('AsyncMock', 'async_test')
+
+
+# TODO: Remove me on 3.8
+class AsyncMock(MagicMock):
+ async def __call__(self, *args, **kwargs):
+ return super(AsyncMock, self).__call__(*args, **kwargs)
+
+
+def async_test(wrapped):
+ """
+ Run a test case via asyncio.
+
+ Example:
+
+ >>> @async_test
+ ... async def lemon_wins():
+ ... assert True
+ """
+
+ @functools.wraps(wrapped)
+ def wrapper(*args, **kwargs):
+ return asyncio.run(wrapped(*args, **kwargs))
+ return wrapper
diff --git a/tests/test_api.py b/tests/test_api.py
new file mode 100644
index 000000000..ce69ef187
--- /dev/null
+++ b/tests/test_api.py
@@ -0,0 +1,106 @@
+import logging
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from bot import api
+from tests.helpers import async_test
+
+
+def test_loop_is_not_running_by_default():
+ assert not api.loop_is_running()
+
+
+@async_test
+async def test_loop_is_running_in_async_test():
+ assert api.loop_is_running()
+
+
+def error_api_response():
+ response = MagicMock()
+ response.status = 999
+ return response
+
+
+def api_log_handler():
+ return api.APILoggingHandler(None)
+
+
+def debug_log_record():
+ return logging.LogRecord(
+ name='my.logger', level=logging.DEBUG,
+ pathname='my/logger.py', lineno=666,
+ msg="Lemon wins", args=(),
+ exc_info=None
+ )
+
+
+def test_response_code_error_default_initialization(error_api_response):
+ error = api.ResponseCodeError(response=error_api_response)
+ assert error.status is error_api_response.status
+ assert not error.response_json
+ assert not error.response_text
+ assert error.response is error_api_response
+
+
+def test_response_code_error_default_representation(error_api_response):
+ error = api.ResponseCodeError(response=error_api_response)
+ assert str(error) == f"Status: {error_api_response.status} Response: "
+
+
+def test_response_code_error_representation_with_nonempty_response_json(error_api_response):
+ error = api.ResponseCodeError(
+ response=error_api_response,
+ response_json={'hello': 'world'}
+ )
+ assert str(error) == f"Status: {error_api_response.status} Response: {{'hello': 'world'}}"
+
+
+def test_response_code_error_representation_with_nonempty_response_text(error_api_response):
+ error = api.ResponseCodeError(
+ response=error_api_response,
+ response_text='Lemon will eat your soul'
+ )
+ assert str(error) == f"Status: {error_api_response.status} Response: Lemon will eat your soul"
+
+
+@patch('bot.api.APILoggingHandler.ship_off')
+def test_emit_appends_to_queue_with_stopped_event_loop(
+ ship_off_patch, api_log_handler, debug_log_record
+):
+ # This is a coroutine so returns something we should await,
+ # but asyncio complains about that. To ease testing, we patch
+ # `ship_off` to just return a regular value instead.
+ ship_off_patch.return_value = 42
+ api_log_handler.emit(debug_log_record)
+
+ assert api_log_handler.queue == [42]
+
+
+def test_emit_ignores_less_than_debug(debug_log_record, api_log_handler):
+ debug_log_record.levelno = logging.DEBUG - 5
+ api_log_handler.emit(debug_log_record)
+ assert not api_log_handler.queue
+
+
+def test_schedule_queued_tasks_for_empty_queue(api_log_handler, caplog):
+ api_log_handler.schedule_queued_tasks()
+ # Logs when tasks are scheduled
+ assert not caplog.records
+
+
+@patch('asyncio.create_task')
+def test_schedule_queued_tasks_for_nonempty_queue(create_task_patch, api_log_handler, caplog):
+ api_log_handler.queue = [555]
+ api_log_handler.schedule_queued_tasks()
+ assert not api_log_handler.queue
+ create_task_patch.assert_called_once_with(555)
+
+ [record] = caplog.records
+ assert record.message == "Scheduled 1 pending logging tasks."
+ assert record.levelno == logging.DEBUG
+ assert record.name == 'bot.api'
+ assert record.__dict__['via_handler']
diff --git a/tests/test_constants.py b/tests/test_constants.py
new file mode 100644
index 000000000..e4a29d994
--- /dev/null
+++ b/tests/test_constants.py
@@ -0,0 +1,23 @@
+import inspect
+
+import pytest
+
+from bot import constants
+
+
+ 'section',
+ (
+ cls
+ for (name, cls) in inspect.getmembers(constants)
+ if hasattr(cls, 'section') and isinstance(cls, type)
+ )
+)
+def test_section_configuration_matches_typespec(section):
+ for (name, annotation) in section.__annotations__.items():
+ value = getattr(section, name)
+
+ if getattr(annotation, '_name', None) in ('Dict', 'List'):
+ pytest.skip("Cannot validate containers yet")
+
+ assert isinstance(value, annotation)
diff --git a/tests/test_converters.py b/tests/test_converters.py
new file mode 100644
index 000000000..3cf774c80
--- /dev/null
+++ b/tests/test_converters.py
@@ -0,0 +1,93 @@
+import asyncio
+from datetime import datetime
+from unittest.mock import MagicMock
+
+import pytest
+from discord.ext.commands import BadArgument
+
+from bot.converters import (
+ ExpirationDate,
+ TagContentConverter,
+ TagNameConverter,
+ ValidPythonIdentifier,
+)
+
+
+ ('value', 'expected'),
+ (
+ # sorry aliens
+ ('2199-01-01T00:00:00', datetime(2199, 1, 1)),
+ )
+)
+def test_expiration_date_converter_for_valid(value: str, expected: datetime):
+ converter = ExpirationDate()
+ assert asyncio.run(converter.convert(None, value)) == expected
+
+
+ ('value', 'expected'),
+ (
+ ('hello', 'hello'),
+ (' h ello ', 'h ello')
+ )
+)
+def test_tag_content_converter_for_valid(value: str, expected: str):
+ assert asyncio.run(TagContentConverter.convert(None, value)) == expected
+
+
+ ('value', 'expected'),
+ (
+ ('', "Tag contents should not be empty, or filled with whitespace."),
+ (' ', "Tag contents should not be empty, or filled with whitespace.")
+ )
+)
+def test_tag_content_converter_for_invalid(value: str, expected: str):
+ context = MagicMock()
+ context.author = 'bob'
+
+ with pytest.raises(BadArgument, match=expected):
+ asyncio.run(TagContentConverter.convert(context, value))
+
+
+ ('value', 'expected'),
+ (
+ ('tracebacks', 'tracebacks'),
+ ('Tracebacks', 'tracebacks'),
+ (' Tracebacks ', 'tracebacks'),
+ )
+)
+def test_tag_name_converter_for_valid(value: str, expected: str):
+ assert asyncio.run(TagNameConverter.convert(None, value)) == expected
+
+
+ ('value', 'expected'),
+ (
+ ('👋', "Don't be ridiculous, you can't use that character!"),
+ ('', "Tag names should not be empty, or filled with whitespace."),
+ (' ', "Tag names should not be empty, or filled with whitespace."),
+ ('42', "Tag names can't be numbers."),
+ # Escape question mark as this is evaluated as regular expression.
+ ('x' * 128, r"Are you insane\? That's way too long!"),
+ )
+)
+def test_tag_name_converter_for_invalid(value: str, expected: str):
+ context = MagicMock()
+ context.author = 'bob'
+
+ with pytest.raises(BadArgument, match=expected):
+ asyncio.run(TagNameConverter.convert(context, value))
+
+
[email protected]('value', ('foo', 'lemon'))
+def test_valid_python_identifier_for_valid(value: str):
+ assert asyncio.run(ValidPythonIdentifier.convert(None, value)) == value
+
+
[email protected]('value', ('nested.stuff', '#####'))
+def test_valid_python_identifier_for_invalid(value: str):
+ with pytest.raises(BadArgument, match=f'`{value}` is not a valid Python identifier'):
+ asyncio.run(ValidPythonIdentifier.convert(None, value))
diff --git a/tests/test_pagination.py b/tests/test_pagination.py
new file mode 100644
index 000000000..11d6541ae
--- /dev/null
+++ b/tests/test_pagination.py
@@ -0,0 +1,29 @@
+from unittest import TestCase
+
+import pytest
+
+from bot import pagination
+
+
+class LinePaginatorTests(TestCase):
+ def setUp(self):
+ self.paginator = pagination.LinePaginator(prefix='', suffix='', max_size=30)
+
+ def test_add_line_raises_on_too_long_lines(self):
+ message = f"Line exceeds maximum page size {self.paginator.max_size - 2}"
+ with pytest.raises(RuntimeError, match=message):
+ self.paginator.add_line('x' * self.paginator.max_size)
+
+ def test_add_line_works_on_small_lines(self):
+ self.paginator.add_line('x' * (self.paginator.max_size - 3))
+
+
+class ImagePaginatorTests(TestCase):
+ def setUp(self):
+ self.paginator = pagination.ImagePaginator()
+
+ def test_add_image_appends_image(self):
+ image = 'lemon'
+ self.paginator.add_image(image)
+
+ assert self.paginator.images == [image]
diff --git a/tests/test_resources.py b/tests/test_resources.py
new file mode 100644
index 000000000..2b17aea64
--- /dev/null
+++ b/tests/test_resources.py
@@ -0,0 +1,18 @@
+import json
+import mimetypes
+from pathlib import Path
+from urllib.parse import urlparse
+
+
+def test_stars_valid():
+ """Validates that `bot/resources/stars.json` contains valid images."""
+
+ path = Path('bot', 'resources', 'stars.json')
+ content = path.read_text()
+ data = json.loads(content)
+
+ for url in data.values():
+ assert urlparse(url).scheme == 'https'
+
+ mimetype, _ = mimetypes.guess_type(url)
+ assert mimetype in ('image/jpeg', 'image/png')
diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/tests/utils/__init__.py
diff --git a/tests/utils/test_checks.py b/tests/utils/test_checks.py
new file mode 100644
index 000000000..7121acebd
--- /dev/null
+++ b/tests/utils/test_checks.py
@@ -0,0 +1,66 @@
+from unittest.mock import MagicMock
+
+import pytest
+
+from bot.utils import checks
+
+
+def context():
+ return MagicMock()
+
+
+def test_with_role_check_without_guild(context):
+ context.guild = None
+
+ assert not checks.with_role_check(context)
+
+
+def test_with_role_check_with_guild_without_required_role(context):
+ context.guild = True
+ context.author.roles = []
+
+ assert not checks.with_role_check(context)
+
+
+def test_with_role_check_with_guild_with_required_role(context):
+ context.guild = True
+ role = MagicMock()
+ role.id = 42
+ context.author.roles = (role,)
+
+ assert checks.with_role_check(context, role.id)
+
+
+def test_without_role_check_without_guild(context):
+ context.guild = None
+
+ assert not checks.without_role_check(context)
+
+
+def test_without_role_check_with_unwanted_role(context):
+ context.guild = True
+ role = MagicMock()
+ role.id = 42
+ context.author.roles = (role,)
+
+ assert not checks.without_role_check(context, role.id)
+
+
+def test_without_role_check_without_unwanted_role(context):
+ context.guild = True
+ role = MagicMock()
+ role.id = 42
+ context.author.roles = (role,)
+
+ assert checks.without_role_check(context, role.id + 10)
+
+
+def test_in_channel_check_for_correct_channel(context):
+ context.channel.id = 42
+ assert checks.in_channel_check(context, context.channel.id)
+
+
+def test_in_channel_check_for_incorrect_channel(context):
+ context.channel.id = 42
+ assert not checks.in_channel_check(context, context.channel.id + 10)
diff --git a/tox.ini b/tox.ini
index 8ff1ff064..d14819d57 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,9 +1,11 @@
[flake8]
max-line-length=120
-application_import_names=bot
docstring-convention=all
+import-order-style=pycharm
+application_import_names=bot,tests
+exclude=.cache,.venv,constants.py
ignore=
- P102,B311,W503,E226,S311,T000
+ B311,W503,E226,S311,T000
# Missing Docstrings
D100,D104,D105,D107,
# Docstring Whitespace
@@ -14,5 +16,4 @@ ignore=
D400,D401,D402,D404,D405,D406,D407,D408,D409,D410,D411,D412,D413,D414,D416,D417
# Type Annotations
TYP002,TYP003,TYP101,TYP102,TYP204,TYP206
-exclude=.cache,.venv,tests,constants.py
-import-order-style=pycharm
+per-file-ignores=tests/*:D,TYP